Files
nats.js/bin/version_manager.ts
Alberto RicartandGitHub 6966b17e8a chore: update @std imports and bump dependencies across modules (#313)
- Replaced `jsr:@std/*` imports with `@std/*` as per deno linter changes.
- Updated `@types/node` to `^24.3.1` and `typescript` to `^5.9.2`.
- Synchronized dependency versions across modules for alignment.
- Adjusted `deno.json` to reflect resolved import paths as per deno linter

Signed-off-by: Alberto Ricart <[email protected]>
2025-09-15 11:42:17 -05:00

1498 lines
49 KiB
TypeScript
Executable File

/**
* Version Manager for NATS.js Monorepo
*
* This tool manages versions across all modules in the NATS.js monorepo.
* It is designed to handle future versions of modules that may not yet exist in registries.
*
* Key features:
* - Check version consistency across all modules
* - Fix version inconsistencies
* - Bump versions using npm's versioning (with --ignore-scripts and --workspaces-update=false by default)
* - Suggest version bumps based on conventional commits
* - List versions from NPM/JSR registries
*
* Note: The bump command uses --ignore-scripts to prevent any npm lifecycle scripts
* (including postversion) from running, and --workspaces-update=false to prevent npm
* from attempting to resolve dependencies that don't exist yet in the registry.
* All operations are local only.
*/
import { parse } from "@std/jsonc/parse";
import * as semver from "@std/semver";
interface PackageJson {
name: string;
version: string;
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
[key: string]: unknown;
}
interface DenoJson {
name?: string;
version?: string;
imports?: Record<string, string>;
[key: string]: unknown;
}
interface Commit {
hash: string;
message: string;
author: string;
date: string;
}
interface VersionSuggestion {
module: string;
currentVersion: string;
suggestedBump: "major" | "minor" | "patch" | "none";
reason: string;
commits: Commit[];
}
const NATS_SCOPE = "@nats-io/";
async function readJsonFile<T>(filePath: string): Promise<T | null> {
try {
const content = await Deno.readTextFile(filePath);
return parse(content) as T;
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
return null;
}
throw error;
}
}
async function writeJsonFile(filePath: string, data: unknown): Promise<void> {
const content = JSON.stringify(data, null, 2);
await Deno.writeTextFile(filePath, content + "\n");
}
async function getAllPackageAndDenoJsonPaths(): Promise<string[]> {
const paths: string[] = [];
for await (const dirEntry of Deno.readDir(".")) {
if (dirEntry.isDirectory && !dirEntry.name.startsWith(".")) {
const packageJsonPath = `${dirEntry.name}/package.json`;
const denoJsonPath = `${dirEntry.name}/deno.json`;
try {
await Deno.stat(packageJsonPath);
paths.push(packageJsonPath);
} catch (e) {
if (!(e instanceof Deno.errors.NotFound)) {
throw e;
}
}
try {
await Deno.stat(denoJsonPath);
paths.push(denoJsonPath);
} catch (e) {
if (!(e instanceof Deno.errors.NotFound)) {
throw e;
}
}
}
}
// Add root package.json and deno.json
try {
await Deno.stat("package.json");
paths.push("package.json");
} catch (e) {
if (!(e instanceof Deno.errors.NotFound)) {
throw e;
}
}
try {
await Deno.stat("deno.json");
paths.push("deno.json");
} catch (e) {
if (!(e instanceof Deno.errors.NotFound)) {
throw e;
}
}
return paths;
}
async function getModuleVersions(): Promise<Map<string, string>> {
const moduleVersions = new Map<string, string>();
const paths = await getAllPackageAndDenoJsonPaths();
// First pass: collect all module versions from their own files
for (const path of paths) {
if (path.endsWith("package.json")) {
const pkg = await readJsonFile<PackageJson>(path);
if (pkg && pkg.name && pkg.version && pkg.name.startsWith(NATS_SCOPE)) {
const existingVersion = moduleVersions.get(pkg.name);
if (existingVersion && existingVersion !== pkg.version) {
console.warn(
`Warning: ${pkg.name} has different versions in package.json (${pkg.version}) and deno.json (${existingVersion})`,
);
// Use the higher version as the expected one
const comparison = semver.compare(
semver.parse(pkg.version),
semver.parse(existingVersion),
);
if (comparison > 0) {
moduleVersions.set(pkg.name, pkg.version);
}
} else {
moduleVersions.set(pkg.name, pkg.version);
}
}
} else if (path.endsWith("deno.json")) {
const denoConfig = await readJsonFile<DenoJson>(path);
if (
denoConfig && denoConfig.name && denoConfig.version &&
denoConfig.name.startsWith(NATS_SCOPE)
) {
const existingVersion = moduleVersions.get(denoConfig.name);
if (existingVersion && existingVersion !== denoConfig.version) {
console.warn(
`Warning: ${denoConfig.name} has different versions in package.json (${existingVersion}) and deno.json (${denoConfig.version})`,
);
// Use the higher version as the expected one
const comparison = semver.compare(
semver.parse(denoConfig.version),
semver.parse(existingVersion),
);
if (comparison > 0) {
moduleVersions.set(denoConfig.name, denoConfig.version);
}
} else {
moduleVersions.set(denoConfig.name, denoConfig.version);
}
}
}
}
return moduleVersions;
}
async function checkVersions() {
const moduleVersions = await getModuleVersions();
const allFiles = await getAllPackageAndDenoJsonPaths();
const inconsistencies: Array<{
file: string;
module: string;
expected: string;
actual: string;
}> = [];
// First check if each module's own package.json and deno.json have the same version
const moduleOwnVersions = new Map<
string,
{ packageVersion?: string; denoVersion?: string }
>();
for (const filePath of allFiles) {
if (filePath.endsWith("package.json")) {
const pkg = await readJsonFile<PackageJson>(filePath);
if (pkg && pkg.name && pkg.version && pkg.name.startsWith(NATS_SCOPE)) {
const existing = moduleOwnVersions.get(pkg.name) || {};
existing.packageVersion = pkg.version;
moduleOwnVersions.set(pkg.name, existing);
}
} else if (filePath.endsWith("deno.json")) {
const denoConfig = await readJsonFile<DenoJson>(filePath);
if (
denoConfig && denoConfig.name && denoConfig.version &&
denoConfig.name.startsWith(NATS_SCOPE)
) {
const existing = moduleOwnVersions.get(denoConfig.name) || {};
existing.denoVersion = denoConfig.version;
moduleOwnVersions.set(denoConfig.name, existing);
}
}
}
// Check for mismatches in module's own files
for (const [moduleName, versions] of moduleOwnVersions) {
if (
versions.packageVersion && versions.denoVersion &&
versions.packageVersion !== versions.denoVersion
) {
// Find the actual file paths
for (const filePath of allFiles) {
if (filePath.endsWith("package.json")) {
const pkg = await readJsonFile<PackageJson>(filePath);
if (pkg && pkg.name === moduleName) {
inconsistencies.push({
file: filePath,
module: `${moduleName} (own version)`,
expected: versions.packageVersion!,
actual: versions.packageVersion!,
});
}
} else if (filePath.endsWith("deno.json")) {
const denoConfig = await readJsonFile<DenoJson>(filePath);
if (denoConfig && denoConfig.name === moduleName) {
inconsistencies.push({
file: filePath,
module: `${moduleName} (own version)`,
expected: versions.packageVersion!,
actual: versions.denoVersion!,
});
}
}
}
}
}
// Build module directory map
const moduleDirs = new Map<string, string>();
for (const filePath of allFiles) {
if (filePath.endsWith("package.json")) {
const pkg = await readJsonFile<PackageJson>(filePath);
if (pkg && pkg.name && pkg.name.startsWith(NATS_SCOPE)) {
const lastSlash = filePath.lastIndexOf("/");
const dir = lastSlash > 0 ? filePath.substring(0, lastSlash) : ".";
moduleDirs.set(pkg.name, dir);
}
} else if (filePath.endsWith("deno.json")) {
const denoConfig = await readJsonFile<DenoJson>(filePath);
if (
denoConfig && denoConfig.name && denoConfig.name.startsWith(NATS_SCOPE)
) {
const lastSlash = filePath.lastIndexOf("/");
const dir = lastSlash > 0 ? filePath.substring(0, lastSlash) : ".";
moduleDirs.set(denoConfig.name, dir);
}
}
}
// Check version.ts files match manifest versions
const modulesWithVersionTs = [
"@nats-io/nats-core",
"@nats-io/transport-node",
"@nats-io/transport-deno",
];
for (const moduleName of modulesWithVersionTs) {
const moduleDir = moduleDirs.get(moduleName);
if (!moduleDir) continue;
const versionTsPath = `${moduleDir}/src/version.ts`;
try {
const versionTsContent = await Deno.readTextFile(versionTsPath);
const versionMatch = versionTsContent.match(
/export const version = "([^"]+)"/,
);
if (versionMatch) {
const versionTsVersion = versionMatch[1];
const expectedVersion = moduleVersions.get(moduleName);
if (expectedVersion && versionTsVersion !== expectedVersion) {
inconsistencies.push({
file: versionTsPath,
module: `${moduleName} (version.ts)`,
expected: expectedVersion,
actual: versionTsVersion,
});
}
}
} catch (e) {
if (!(e instanceof Deno.errors.NotFound)) {
console.error(`Error reading ${versionTsPath}: ${e}`);
}
}
}
// Now check dependencies
for (const filePath of allFiles) {
if (filePath.endsWith("package.json")) {
const pkg = await readJsonFile<PackageJson>(filePath);
if (!pkg) continue;
const checkDependencies = (deps?: Record<string, string>) => {
if (!deps) return;
for (const depName in deps) {
if (depName.startsWith(NATS_SCOPE)) {
const expectedVersion = moduleVersions.get(depName);
const actualVersion = deps[depName];
if (expectedVersion && actualVersion !== expectedVersion) {
inconsistencies.push({
file: filePath,
module: depName,
expected: expectedVersion,
actual: actualVersion,
});
}
}
}
};
checkDependencies(pkg.dependencies);
checkDependencies(pkg.devDependencies);
} else if (filePath.endsWith("deno.json")) {
const denoConfig = await readJsonFile<DenoJson>(filePath);
if (!denoConfig || !denoConfig.imports) continue;
for (const importPath in denoConfig.imports) {
const importString = denoConfig.imports[importPath];
const match = importString.match(/jsr:(@nats-io\/[^@]+)@([\d\.-]+)/);
if (match) {
const depName = match[1];
const actualVersion = match[2];
const expectedVersion = moduleVersions.get(depName);
if (expectedVersion && actualVersion !== expectedVersion) {
inconsistencies.push({
file: filePath,
module: depName,
expected: expectedVersion,
actual: actualVersion,
});
}
}
}
}
}
if (inconsistencies.length === 0) {
console.log("All internal NATS module versions are consistent.");
} else {
console.log(`Found ${inconsistencies.length} version inconsistencies:\n`);
console.table(inconsistencies);
}
return inconsistencies.length > 0;
}
async function fixVersions() {
const moduleVersions = await getModuleVersions();
const allFiles = await getAllPackageAndDenoJsonPaths();
let fixedCount = 0;
// First fix module's own version mismatches
for (const filePath of allFiles) {
if (filePath.endsWith("deno.json")) {
const denoConfig = await readJsonFile<DenoJson>(filePath);
if (
denoConfig && denoConfig.name && denoConfig.version &&
denoConfig.name.startsWith(NATS_SCOPE)
) {
const expectedVersion = moduleVersions.get(denoConfig.name);
if (expectedVersion && denoConfig.version !== expectedVersion) {
denoConfig.version = expectedVersion;
await writeJsonFile(filePath, denoConfig);
console.log(
`Fixed ${filePath}: ${denoConfig.name} own version to ${expectedVersion}`,
);
fixedCount++;
}
}
}
}
// Then fix dependencies
for (const filePath of allFiles) {
if (filePath.endsWith("package.json")) {
const pkg = await readJsonFile<PackageJson>(filePath);
if (!pkg) continue;
let changed = false;
const fixDependencies = (deps?: Record<string, string>) => {
if (!deps) return;
for (const depName in deps) {
if (depName.startsWith(NATS_SCOPE)) {
const expectedVersion = moduleVersions.get(depName);
const actualVersion = deps[depName];
if (expectedVersion && actualVersion !== expectedVersion) {
deps[depName] = expectedVersion;
console.log(
`Fixed ${filePath}: ${depName} to ${expectedVersion}`,
);
changed = true;
fixedCount++;
}
}
}
};
fixDependencies(pkg.dependencies);
fixDependencies(pkg.devDependencies);
if (changed) {
await writeJsonFile(filePath, pkg);
}
} else if (filePath.endsWith("deno.json")) {
const denoConfig = await readJsonFile<DenoJson>(filePath);
if (!denoConfig || !denoConfig.imports) continue;
let changed = false;
for (const importPath in denoConfig.imports) {
const importString = denoConfig.imports[importPath];
const match = importString.match(/jsr:(@nats-io\/[^@]+)@([\d\.-]+)/);
if (match) {
const depName = match[1];
const actualVersion = match[2];
const expectedVersion = moduleVersions.get(depName);
if (expectedVersion && actualVersion !== expectedVersion) {
denoConfig.imports[importPath] =
`jsr:${depName}@${expectedVersion}`;
console.log(
`Fixed ${filePath}: ${depName} to ${expectedVersion}`,
);
changed = true;
fixedCount++;
}
}
}
if (changed) {
await writeJsonFile(filePath, denoConfig);
}
}
}
// Fix version.ts files
const modulesWithVersionTs = [
"@nats-io/nats-core",
"@nats-io/transport-node",
"@nats-io/transport-deno",
];
// Build module directory map
const moduleDirs = new Map<string, string>();
for (const filePath of allFiles) {
if (filePath.endsWith("package.json")) {
const pkg = await readJsonFile<PackageJson>(filePath);
if (pkg && pkg.name && pkg.name.startsWith(NATS_SCOPE)) {
const lastSlash = filePath.lastIndexOf("/");
const dir = lastSlash > 0 ? filePath.substring(0, lastSlash) : ".";
moduleDirs.set(pkg.name, dir);
}
} else if (filePath.endsWith("deno.json")) {
const denoConfig = await readJsonFile<DenoJson>(filePath);
if (
denoConfig && denoConfig.name && denoConfig.name.startsWith(NATS_SCOPE)
) {
const lastSlash = filePath.lastIndexOf("/");
const dir = lastSlash > 0 ? filePath.substring(0, lastSlash) : ".";
moduleDirs.set(denoConfig.name, dir);
}
}
}
for (const moduleName of modulesWithVersionTs) {
const moduleDir = moduleDirs.get(moduleName);
if (!moduleDir) continue;
const expectedVersion = moduleVersions.get(moduleName);
if (!expectedVersion) continue;
const versionTsPath = `${moduleDir}/src/version.ts`;
try {
const versionTsContent = await Deno.readTextFile(versionTsPath);
const versionMatch = versionTsContent.match(
/export const version = "([^"]+)"/,
);
if (versionMatch && versionMatch[1] !== expectedVersion) {
const versionContent = `// This file is generated - do not edit
export const version = "${expectedVersion}";
`;
await Deno.writeTextFile(versionTsPath, versionContent);
console.log(`Fixed ${versionTsPath}: version to ${expectedVersion}`);
fixedCount++;
}
} catch (e) {
if (!(e instanceof Deno.errors.NotFound)) {
console.error(`Error updating ${versionTsPath}: ${e}`);
}
}
}
console.log(`Fixed ${fixedCount} version inconsistencies.`);
}
async function bumpVersion(
moduleName: string,
bumpType: string,
preid?: string,
isDependent: boolean = false,
) {
const allFiles = await getAllPackageAndDenoJsonPaths();
let originalVersion: string | undefined;
let newVersion: string | undefined;
let moduleDir: string | undefined;
// Find the module directory and its package.json or deno.json
for (const filePath of allFiles) {
if (filePath.endsWith("package.json")) {
const content = await readJsonFile<PackageJson>(filePath);
if (content && content.name === moduleName && content.version) {
originalVersion = content.version;
// Extract directory from path
const lastSlash = filePath.lastIndexOf("/");
moduleDir = lastSlash > 0 ? filePath.substring(0, lastSlash) : ".";
break;
}
} else if (filePath.endsWith("deno.json")) {
const content = await readJsonFile<DenoJson>(filePath);
if (content && content.name === moduleName && content.version) {
originalVersion = content.version;
// Extract directory from path
const lastSlash = filePath.lastIndexOf("/");
moduleDir = lastSlash > 0 ? filePath.substring(0, lastSlash) : ".";
break;
}
}
}
if (!originalVersion || !moduleDir) {
console.error(`Could not find module ${moduleName} to bump its version.`);
return;
}
// Handle release bump type to remove prerelease qualifier
if (bumpType === "release") {
const currentSemver = semver.parse(originalVersion);
if (!currentSemver.prerelease || currentSemver.prerelease.length === 0) {
console.log(
`${moduleName} is already a release version: ${originalVersion}`,
);
return;
}
// Remove prerelease suffix
newVersion =
`${currentSemver.major}.${currentSemver.minor}.${currentSemver.patch}`;
// Update package.json manually if it exists
const pkg = await readJsonFile<PackageJson>(`${moduleDir}/package.json`);
if (pkg) {
pkg.version = newVersion;
await writeJsonFile(`${moduleDir}/package.json`, pkg);
}
// Update deno.json if it exists (for deno-only modules)
const denoJsonPath = `${moduleDir}/deno.json`;
const denoConfig = await readJsonFile<DenoJson>(denoJsonPath);
if (denoConfig && denoConfig.version) {
denoConfig.version = newVersion;
await writeJsonFile(denoJsonPath, denoConfig);
}
console.log(
`Released ${moduleName} from ${originalVersion} to ${newVersion}`,
);
} else {
// Calculate version using semver for all modules
const currentSemver = semver.parse(originalVersion);
// Handle special bump types with preid
if (
bumpType === "premajor" || bumpType === "preminor" ||
bumpType === "prepatch" || bumpType === "prerelease"
) {
// For pre-versions, use the preid if provided
const prereleaseIdentifier = preid || undefined;
if (
bumpType === "prerelease" && currentSemver.prerelease &&
currentSemver.prerelease.length > 0
) {
// If already a prerelease, just increment the prerelease version
newVersion = semver.format(
semver.increment(currentSemver, "prerelease", prereleaseIdentifier)!,
);
} else {
// Otherwise use the appropriate pre-bump type
const releaseType = bumpType as
| "premajor"
| "preminor"
| "prepatch"
| "prerelease";
newVersion = semver.format(
semver.increment(currentSemver, releaseType, prereleaseIdentifier)!,
);
}
} else {
// Regular version bumps
const releaseType = bumpType as "major" | "minor" | "patch";
newVersion = semver.format(semver.increment(currentSemver, releaseType)!);
}
console.log(
`Bumped ${moduleName} from ${originalVersion} to ${newVersion}`,
);
// Update package.json if it exists
const pkg = await readJsonFile<PackageJson>(`${moduleDir}/package.json`);
if (pkg) {
pkg.version = newVersion;
await writeJsonFile(`${moduleDir}/package.json`, pkg);
}
// Update deno.json if it exists
const denoJsonPath = `${moduleDir}/deno.json`;
const denoConfig = await readJsonFile<DenoJson>(denoJsonPath);
if (denoConfig && denoConfig.version) {
denoConfig.version = newVersion;
await writeJsonFile(denoJsonPath, denoConfig);
console.log(`Updated ${denoJsonPath} to version ${newVersion}`);
}
}
// Update version.ts if it exists
const versionTsPath = `${moduleDir}/src/version.ts`;
try {
await Deno.stat(versionTsPath);
const versionContent = `// This file is generated - do not edit
export const version = "${newVersion}";
`;
await Deno.writeTextFile(versionTsPath, versionContent);
console.log(`Updated ${versionTsPath} to version ${newVersion}`);
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) {
console.error(`Error updating version.ts: ${error}`);
}
}
// Define module dependencies
const MODULE_DEPENDENCIES: Record<string, string[]> = {
"@nats-io/nats-core": [],
"@nats-io/transport-node": ["@nats-io/nats-core"],
"@nats-io/transport-deno": ["@nats-io/nats-core"],
"@nats-io/jetstream": ["@nats-io/nats-core"],
"@nats-io/kv": ["@nats-io/nats-core", "@nats-io/jetstream"],
"@nats-io/obj": ["@nats-io/nats-core", "@nats-io/jetstream"],
"@nats-io/services": ["@nats-io/nats-core"],
};
// Find all modules that depend on the bumped module
const dependentModules = new Set<string>();
for (const [mod, deps] of Object.entries(MODULE_DEPENDENCIES)) {
if (deps.includes(moduleName)) {
dependentModules.add(mod);
}
}
// If we bumped a dependency, we need to bump dependents too (but only if not already a dependent)
if (
!isDependent && dependentModules.size > 0 &&
(bumpType === "major" || bumpType === "minor")
) {
console.log(`\nPropagating ${bumpType} bump to dependent modules...`);
for (const depModule of dependentModules) {
console.log(`\nBumping ${depModule} due to ${moduleName} dependency...`);
await bumpVersion(
depModule,
bumpType === "major" ? "major" : "minor",
preid,
true,
);
}
}
// Update all references to the bumped module
let updatedReferences = 0;
for (const filePath of allFiles) {
if (
filePath.endsWith("package.json") || filePath.endsWith("deno.json")
) {
let content: PackageJson | DenoJson | null = null;
if (filePath.endsWith("package.json")) {
content = await readJsonFile<PackageJson>(filePath);
} else {
content = await readJsonFile<DenoJson>(filePath);
}
if (!content) continue;
let changed = false;
// Update package.json dependencies
const updateDeps = (deps?: Record<string, string>) => {
if (!deps) return;
if (deps[moduleName] && deps[moduleName] !== newVersion) {
deps[moduleName] = newVersion;
changed = true;
updatedReferences++;
}
};
updateDeps((content as PackageJson).dependencies);
updateDeps((content as PackageJson).devDependencies);
// Update deno.json imports
if ((content as DenoJson).imports) {
for (const importPath in (content as DenoJson).imports) {
const importString = (content as DenoJson).imports![importPath];
// Escape special regex characters in module name
const escapedModuleName = moduleName.replace(
/[.*+?^${}()|[\]\\]/g,
"\$&",
);
const match = importString.match(
new RegExp(`(jsr:${escapedModuleName})@([\\d.-]+)`),
);
if (match && match[2] !== newVersion) {
(content as DenoJson).imports![importPath] = `${
match[1]
}@${newVersion}`;
changed = true;
updatedReferences++;
}
}
}
if (changed) {
await writeJsonFile(filePath, content);
}
}
}
console.log(
`Updated ${updatedReferences} references to ${moduleName} to version ${newVersion}.`,
);
}
async function fetchRemoteVersions(
moduleName?: string,
showAll: boolean = false,
) {
const results: Array<{
module: string;
local?: string;
npmLatest?: string;
npmNext?: string;
jsrLatest?: string;
jsrPrerelease?: string;
}> = [];
if (moduleName) {
// Single module specified - use it as-is
const result = await fetchModuleVersions(moduleName, showAll);
if (result) results.push(result);
} else {
// No module specified - fetch all local NATS modules
const modules = Array.from((await getModuleVersions()).keys());
// Define sort order
const sortOrder = [
"@nats-io/nats-core",
"@nats-io/jetstream",
"@nats-io/kv",
"@nats-io/obj",
"@nats-io/services",
"@nats-io/transport-node",
"@nats-io/transport-deno",
];
// Sort modules according to defined order
modules.sort((a, b) => {
const aIndex = sortOrder.indexOf(a);
const bIndex = sortOrder.indexOf(b);
// If both are in sort order, sort by their position
if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex;
}
// If only a is in sort order, it comes first
if (aIndex !== -1) return -1;
// If only b is in sort order, it comes first
if (bIndex !== -1) return 1;
// Neither in sort order, sort alphabetically
return a.localeCompare(b);
});
for (const mod of modules) {
const result = await fetchModuleVersions(mod, showAll);
if (result) results.push(result);
}
}
if (!showAll && results.length > 0) {
// Display as table
console.log();
console.table(results);
}
}
async function fetchModuleVersions(
moduleName: string,
showAll: boolean = false,
) {
const result = {
module: moduleName,
local: undefined as string | undefined,
npmLatest: undefined as string | undefined,
npmNext: undefined as string | undefined,
jsrLatest: undefined as string | undefined,
jsrPrerelease: undefined as string | undefined,
};
if (showAll) {
console.log(`\nFetching versions for ${moduleName}:`);
}
// Try JSR first - JSR requires scoped packages
if (moduleName.startsWith("@")) {
try {
const jsrUrl = `https://jsr.io/${moduleName}/meta.json`;
const response = await fetch(jsrUrl);
if (response.ok) {
const data = await response.json();
const versions = Object.keys(data.versions || {});
if (showAll) {
const displayVersions = versions.slice(0, 10);
console.log(
` JSR: ${displayVersions.join(", ")}${
displayVersions.length >= 10 ? ", ..." : ""
}`,
);
} else {
// Sort versions using semver
const sortedVersions = versions.sort((a, b) =>
semver.compare(semver.parse(b), semver.parse(a))
);
// Find latest stable and latest prerelease
result.jsrLatest = data.latest || sortedVersions.find((v) =>
!v.includes("-")
) || sortedVersions[0];
// Find the latest prerelease
result.jsrPrerelease = sortedVersions.find((v) => v.includes("-"));
}
}
} catch (_) {
// JSR fetch failed, ignore
}
}
// Try NPM - works with both scoped and unscoped packages
try {
const npmUrl = `https://registry.npmjs.org/${moduleName}`;
const response = await fetch(npmUrl);
if (response.ok) {
const data = await response.json();
if (showAll) {
const versions = Object.keys(data.versions || {}).reverse().slice(
0,
10,
);
console.log(
` NPM: ${versions.join(", ")}${
versions.length >= 10 ? ", ..." : ""
}`,
);
} else {
// Use dist-tags for latest versions
if (data["dist-tags"]) {
result.npmLatest = data["dist-tags"]["latest"];
result.npmNext = data["dist-tags"]["next"];
}
}
}
} catch (_) {
// NPM fetch failed, ignore
}
// Get local version if it exists
const localVersions = await getModuleVersions();
result.local = localVersions.get(moduleName);
if (showAll && result.local) {
console.log(` Local: ${result.local}`);
}
return result;
}
async function getLastReleaseTag(): Promise<string | null> {
try {
const cmd = new Deno.Command("git", {
args: ["describe", "--tags", "--abbrev=0"],
stdout: "piped",
stderr: "piped",
});
const { code, stdout } = await cmd.output();
if (code === 0) {
return new TextDecoder().decode(stdout).trim();
}
} catch (_) {
// Ignore errors
}
return null;
}
async function getCommitsSinceTag(
tag: string,
path?: string,
): Promise<Commit[]> {
const commits: Commit[] = [];
try {
const args = ["log", `${tag}..HEAD`, "--pretty=format:%H|%s|%an|%aI"];
if (path) {
args.push("--", path);
}
const cmd = new Deno.Command("git", {
args,
stdout: "piped",
stderr: "piped",
});
const { code, stdout } = await cmd.output();
if (code === 0) {
const output = new TextDecoder().decode(stdout).trim();
if (output) {
const lines = output.split("\n");
for (const line of lines) {
const [hash, message, author, date] = line.split("|");
commits.push({ hash, message, author, date });
}
}
}
} catch (_) {
// Ignore errors
}
return commits;
}
function analyzeCommits(
commits: Commit[],
): { suggestedBump: "major" | "minor" | "patch" | "none"; reason: string } {
if (commits.length === 0) {
return { suggestedBump: "none", reason: "No changes" };
}
let hasMajor = false;
let hasMinor = false;
let hasPatch = false;
const changeTypes = new Set<string>();
for (const commit of commits) {
const message = commit.message;
const lowerMessage = message.toLowerCase();
// Check for breaking changes
if (
lowerMessage.includes("breaking change") ||
lowerMessage.includes("breaking:") || message.startsWith("!:") ||
/^[a-z]+!(\([^)]+\))?:/.test(message)
) {
hasMajor = true;
changeTypes.add("breaking");
} // Check for features
else if (/^feat(\([^)]+\))?:/i.test(message)) {
hasMinor = true;
changeTypes.add("features");
} // Check for fixes
else if (/^fix(\([^)]+\))?:/i.test(message)) {
hasPatch = true;
changeTypes.add("fixes");
} // Check for performance improvements
else if (/^perf(\([^)]+\))?:/i.test(message)) {
hasPatch = true;
changeTypes.add("performance");
} // Check for refactoring
else if (/^refactor(\([^)]+\))?:/i.test(message)) {
hasPatch = true;
changeTypes.add("refactoring");
} // Check for other changes
else if (
/^(test|docs|style|build|ci)(\([^)]+\))?:/i.test(message) ||
(/^chore(\([^)]+\))?:/i.test(message) && !lowerMessage.includes("deps"))
) {
hasPatch = true;
changeTypes.add("maintenance");
}
}
// Build concise reason string
let reason: string;
if (changeTypes.has("breaking")) {
reason = "Breaking changes";
} else if (changeTypes.has("features")) {
const others = Array.from(changeTypes).filter((t) => t !== "features");
reason = others.length > 0
? `Features + ${others.join(", ")}`
: "Features added";
} else if (changeTypes.has("fixes")) {
const others = Array.from(changeTypes).filter((t) => t !== "fixes");
reason = others.length > 0 ? `Fixes + ${others.join(", ")}` : "Bug fixes";
} else if (changeTypes.has("performance")) {
reason = "Performance improvements";
} else if (changeTypes.has("refactoring")) {
reason = "Code refactoring";
} else if (changeTypes.has("maintenance")) {
reason = "Maintenance";
} else {
reason = "No significant changes";
}
if (hasMajor) {
return { suggestedBump: "major", reason };
} else if (hasMinor) {
return { suggestedBump: "minor", reason };
} else if (hasPatch) {
return { suggestedBump: "patch", reason };
} else {
return { suggestedBump: "none", reason };
}
}
async function suggestVersionBumps(
showDetails: boolean = false,
apply: boolean = false,
prerelease: boolean = true,
): Promise<void> {
const lastTag = await getLastReleaseTag();
if (!lastTag) {
console.error("Could not find the last release tag");
return;
}
console.log(`Analyzing commits since last release: ${lastTag}\n`);
const moduleVersions = await getModuleVersions();
const suggestions: VersionSuggestion[] = [];
// Get module directories - reuse logic from checkVersions/fixVersions
const moduleDirs = new Map<string, string>();
const allPaths = await getAllPackageAndDenoJsonPaths();
for (const filePath of allPaths) {
if (filePath.endsWith("package.json")) {
const pkg = await readJsonFile<PackageJson>(filePath);
if (pkg && pkg.name && pkg.name.startsWith(NATS_SCOPE)) {
const lastSlash = filePath.lastIndexOf("/");
const dir = lastSlash > 0 ? filePath.substring(0, lastSlash) : ".";
moduleDirs.set(pkg.name, dir);
}
} else if (filePath.endsWith("deno.json")) {
const denoConfig = await readJsonFile<DenoJson>(filePath);
if (
denoConfig && denoConfig.name && denoConfig.name.startsWith(NATS_SCOPE)
) {
const lastSlash = filePath.lastIndexOf("/");
const dir = lastSlash > 0 ? filePath.substring(0, lastSlash) : ".";
moduleDirs.set(denoConfig.name, dir);
}
}
}
// Analyze each module
for (const [moduleName, currentVersion] of moduleVersions) {
const moduleDir = moduleDirs.get(moduleName);
if (!moduleDir) {
console.log(`Warning: No directory found for module ${moduleName}`);
continue;
}
// Get commits for this module
const commits = await getCommitsSinceTag(lastTag, moduleDir);
// Also check for commits that mention the module in the message
const allCommits = await getCommitsSinceTag(lastTag);
const moduleShortName = moduleName.replace("@nats-io/", "");
const relevantCommits = allCommits.filter((commit) => {
const msg = commit.message.toLowerCase();
return msg.includes(moduleShortName) ||
msg.includes(`(${moduleShortName})`) ||
msg.includes(`[${moduleShortName}]`);
});
// Combine and deduplicate commits
const combinedCommits = [...commits];
for (const commit of relevantCommits) {
if (!combinedCommits.find((c) => c.hash === commit.hash)) {
combinedCommits.push(commit);
}
}
const analysis = analyzeCommits(combinedCommits);
suggestions.push({
module: moduleName,
currentVersion,
suggestedBump: analysis.suggestedBump,
reason: analysis.reason,
commits: combinedCommits,
});
}
// Define module dependencies (same as in bumpVersion)
const MODULE_DEPENDENCIES: Record<string, string[]> = {
"@nats-io/nats-core": [],
"@nats-io/transport-node": ["@nats-io/nats-core"],
"@nats-io/transport-deno": ["@nats-io/nats-core"],
"@nats-io/jetstream": ["@nats-io/nats-core"],
"@nats-io/kv": ["@nats-io/nats-core", "@nats-io/jetstream"],
"@nats-io/obj": ["@nats-io/nats-core", "@nats-io/jetstream"],
"@nats-io/services": ["@nats-io/nats-core"],
};
// Propagate version bumps through dependencies
for (const suggestion of suggestions) {
if (
suggestion.suggestedBump !== "none" &&
suggestion.suggestedBump !== "patch"
) {
// Find all modules that depend on this module
for (const [depModule, deps] of Object.entries(MODULE_DEPENDENCIES)) {
if (deps.includes(suggestion.module)) {
// Find the suggestion for the dependent module
const depSuggestion = suggestions.find((s) => s.module === depModule);
if (depSuggestion) {
// Store the original bump type for comparison
const originalBump = depSuggestion.suggestedBump;
const moduleShortName = suggestion.module.replace("@nats-io/", "");
if (suggestion.suggestedBump === "major") {
if (depSuggestion.suggestedBump !== "major") {
depSuggestion.suggestedBump = "major";
}
// Always add dependency info to reason
if (
!depSuggestion.reason.includes(`${moduleShortName} major bump`)
) {
if (depSuggestion.reason === "No changes") {
depSuggestion.reason =
`Dependency ${moduleShortName} requires major bump`;
} else if (
originalBump === "patch" || originalBump === "none"
) {
// Module had patch-level changes but needs major due to dependency
depSuggestion.reason =
`${depSuggestion.reason} (patch→major due to ${moduleShortName})`;
} else {
depSuggestion.reason = depSuggestion.reason +
` + ${moduleShortName} major bump`;
}
}
} else if (
suggestion.suggestedBump === "minor" &&
depSuggestion.suggestedBump !== "major"
) {
if (depSuggestion.suggestedBump !== "minor") {
depSuggestion.suggestedBump = "minor";
}
// Always add dependency info to reason
if (
!depSuggestion.reason.includes(`${moduleShortName} minor bump`)
) {
if (depSuggestion.reason === "No changes") {
depSuggestion.reason =
`Dependency ${moduleShortName} requires minor bump`;
} else if (
originalBump === "patch" || originalBump === "none"
) {
// Module had patch-level changes but needs minor due to dependency
depSuggestion.reason =
`${depSuggestion.reason} (patch→minor due to ${moduleShortName})`;
} else {
depSuggestion.reason = depSuggestion.reason +
` + ${moduleShortName} minor bump`;
}
}
}
}
}
}
}
}
// Sort suggestions by module order
const sortOrder = [
"@nats-io/nats-core",
"@nats-io/jetstream",
"@nats-io/kv",
"@nats-io/obj",
"@nats-io/services",
"@nats-io/transport-node",
"@nats-io/transport-deno",
];
suggestions.sort((a, b) => {
const aIndex = sortOrder.indexOf(a.module);
const bIndex = sortOrder.indexOf(b.module);
if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex;
}
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
return a.module.localeCompare(b.module);
});
// Prepare table data
const tableData = suggestions.map((suggestion) => {
let newVersion = suggestion.currentVersion;
if (suggestion.suggestedBump !== "none") {
const currentSemver = semver.parse(suggestion.currentVersion);
// If prerelease is true and we're suggesting a version bump, create a prerelease version
if (prerelease) {
// When bumping to a new major/minor/patch from a prerelease, the prerelease counter resets to 0
// For example: 3.0.3-1 with minor bump becomes 3.1.0-0, not 3.1.0-2
if (currentSemver.prerelease && currentSemver.prerelease.length > 0) {
// Already in prerelease, we need to:
// 1. First bump to the target version (which removes prerelease)
// 2. Then convert to prerelease which will add -0
const releaseType = suggestion.suggestedBump as
| "major"
| "minor"
| "patch";
const baseVersion = semver.increment(currentSemver, releaseType);
// Now add prerelease suffix, which will be -0
newVersion = `${baseVersion!.major}.${baseVersion!.minor}.${
baseVersion!.patch
}-0`;
} else {
// Not a prerelease, use normal pre-bump
const preBumpType = suggestion.suggestedBump === "major"
? "premajor"
: suggestion.suggestedBump === "minor"
? "preminor"
: "prepatch";
const incremented = semver.increment(
currentSemver,
preBumpType as "premajor" | "preminor" | "prepatch",
);
newVersion = semver.format(incremented!);
}
} else {
newVersion = semver.format(
semver.increment(currentSemver, suggestion.suggestedBump)!,
);
}
}
return {
module: suggestion.module,
current: suggestion.currentVersion,
suggested: suggestion.suggestedBump === "none"
? suggestion.currentVersion
: newVersion,
bump: suggestion.suggestedBump,
commits: suggestion.commits.length,
reason: suggestion.reason,
};
});
// Display main table
console.log("Version Bump Suggestions:");
console.log("========================\n");
console.table(tableData);
// Show detailed commits for modules needing bumps (only if --show-details flag is set)
if (showDetails) {
const modulesNeedingBumps = suggestions.filter((s) =>
s.suggestedBump !== "none"
);
if (modulesNeedingBumps.length > 0) {
console.log("\nDetailed Changes:");
console.log("-----------------");
for (const suggestion of modulesNeedingBumps) {
console.log(
`\n${suggestion.module} (${suggestion.commits.length} commits):`,
);
const commitDetails = suggestion.commits.slice(0, 5).map((commit) => ({
hash: commit.hash.substring(0, 7),
message: commit.message.length > 60
? commit.message.substring(0, 57) + "..."
: commit.message,
author: commit.author,
}));
console.table(commitDetails);
if (suggestion.commits.length > 5) {
console.log(
` ... and ${suggestion.commits.length - 5} more commits`,
);
}
}
}
}
// Apply the suggested bumps if --apply flag is set
if (apply) {
const modulesToBump = suggestions.filter((s) => s.suggestedBump !== "none");
if (modulesToBump.length === 0) {
console.log("\nNo version bumps needed.");
return;
}
console.log("\nApplying version bumps...");
console.log("========================\n");
// Sort modules by dependency order to bump dependencies first
const sortedModules = [...modulesToBump].sort((a, b) => {
const aIndex = sortOrder.indexOf(a.module);
const bIndex = sortOrder.indexOf(b.module);
if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex;
}
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
return a.module.localeCompare(b.module);
});
// Apply bumps to each module
// Note: We pass isDependent=true to prevent automatic propagation since we're handling all modules
for (const suggestion of sortedModules) {
// If prerelease is true, convert bump type to pre-version
let bumpType: string = suggestion.suggestedBump;
if (prerelease && suggestion.suggestedBump !== "none") {
if (suggestion.suggestedBump === "major") bumpType = "premajor";
else if (suggestion.suggestedBump === "minor") bumpType = "preminor";
else if (suggestion.suggestedBump === "patch") bumpType = "prepatch";
}
console.log(`Bumping ${suggestion.module} (${bumpType})...`);
await bumpVersion(suggestion.module, bumpType, undefined, true);
console.log("");
}
// Run a version check to ensure everything is consistent
console.log("\nVerifying version consistency...");
const hasInconsistencies = await checkVersions();
if (hasInconsistencies) {
console.error(
"\nWarning: Version inconsistencies detected after bumping. Running fix...",
);
await fixVersions();
// Check again
const stillHasInconsistencies = await checkVersions();
if (stillHasInconsistencies) {
console.error("\nError: Version inconsistencies persist after fix.");
Deno.exit(1);
}
} else {
console.log("\nAll versions bumped successfully!");
}
}
}
const args = Deno.args;
const command = args[0];
if (command === "check") {
const hasInconsistencies = await checkVersions();
if (hasInconsistencies) {
Deno.exit(1);
}
} else if (command === "fix") {
await fixVersions();
} else if (command === "bump") {
const moduleName = args[1];
const bumpType = args[2];
// Check for --preid flag
const preidIndex = args.indexOf("--preid");
const preid = preidIndex !== -1 && args[preidIndex + 1]
? args[preidIndex + 1]
: undefined;
const validBumpTypes = [
"major",
"minor",
"patch",
"premajor",
"preminor",
"prepatch",
"prerelease",
"release",
];
if (!moduleName || !bumpType || !validBumpTypes.includes(bumpType)) {
console.error(
"Usage: deno run -A bin/version_manager.ts bump <module_name|all> <version_type> [--preid <identifier>]",
);
console.error(
" module_name: specific module name or 'all' to bump all modules",
);
console.error(
" version_type: major | minor | patch | premajor | preminor | prepatch | prerelease | release",
);
console.error(
" --preid: Identifier for prerelease versions (e.g., alpha, beta, rc)",
);
Deno.exit(1);
}
if (moduleName === "all") {
// Bump all modules
const allFiles = await getAllPackageAndDenoJsonPaths();
const modules: string[] = [];
// Collect all module names
for (const filePath of allFiles) {
if (filePath.endsWith("package.json")) {
const pkg = await readJsonFile<PackageJson>(filePath);
if (pkg && pkg.name && pkg.name.startsWith(NATS_SCOPE)) {
modules.push(pkg.name);
}
} else if (filePath.endsWith("deno.json")) {
const denoConfig = await readJsonFile<DenoJson>(filePath);
if (
denoConfig && denoConfig.name &&
denoConfig.name.startsWith(NATS_SCOPE)
) {
modules.push(denoConfig.name);
}
}
}
// Remove duplicates and sort
const uniqueModules = [...new Set(modules)].sort();
console.log(
`Bumping all modules (${uniqueModules.length} total) to ${bumpType}...\n`,
);
// Bump each module
for (const module of uniqueModules) {
console.log(`Bumping ${module}...`);
await bumpVersion(module, bumpType, preid, true);
console.log("");
}
// Run version check and fix if needed
console.log("\nVerifying version consistency...");
const hasInconsistencies = await checkVersions();
if (hasInconsistencies) {
console.error("\nFixing version inconsistencies...");
await fixVersions();
}
console.log("\nAll modules bumped successfully!");
} else {
await bumpVersion(moduleName, bumpType, preid);
}
} else if (command === "ls" || command === "list") {
// Check for --all flag
const showAll = args.includes("--all");
// Get module name (skip --all if it exists)
const moduleName = args.find((arg) =>
arg !== "ls" && arg !== "list" && arg !== "--all"
);
await fetchRemoteVersions(moduleName, showAll);
} else if (command === "suggest") {
const showDetails = args.includes("--show-details");
const apply = args.includes("--apply");
const noPrerelease = args.includes("--no-prerelease");
await suggestVersionBumps(showDetails, apply, !noPrerelease);
} else {
console.log("Usage:");
console.log(" deno run -A bin/version_manager.ts check");
console.log(" deno run -A bin/version_manager.ts fix");
console.log(
" deno run -A bin/version_manager.ts bump <module_name|all> <version_type> [--preid <identifier>]",
);
console.log(
" module_name: specific module name or 'all' to bump all modules",
);
console.log(
" version_type: major | minor | patch | premajor | preminor | prepatch | prerelease | release",
);
console.log(
" --preid: Identifier for prerelease versions (e.g., alpha, beta, rc)",
);
console.log(
" deno run -A bin/version_manager.ts ls [module_name] [--all]",
);
console.log(
" deno run -A bin/version_manager.ts suggest [--show-details] [--apply] [--no-prerelease]",
);
console.log(
" --show-details: Show detailed commit information for modules needing bumps",
);
console.log(" --apply: Apply all suggested version bumps automatically");
console.log(
" --no-prerelease: Generate stable versions instead of prerelease versions",
);
Deno.exit(1);
}