From b8dc83d4586517cce76fa934cdee55ff391466b9 Mon Sep 17 00:00:00 2001 From: silverwind Date: Wed, 15 Apr 2026 16:32:11 +0200 Subject: [PATCH] Add -V, --verbose flag Log timestamped progress to stderr for debugging and CI: every subprocess invocation, base-version source, file writes, commands, git skips, release creation, and forge API request/response. Colors (magenta/green/red) applied only when stderr is a TTY. Co-Authored-By: Claude (Opus 4.6) --- README.md | 1 + index.ts | 41 +++++++++++++++++++++++++++++++++++------ utils.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ef0946f..e44d305 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ usage: versions [options] patch|minor|major|prerelease [files...] -g, --gitless Do not perform any git action like creating commit and tag -D, --dry Do not create a tag or commit, just print what would be done -R, --release Create a GitHub or Gitea release with the changelog as body + -V, --verbose Print verbose output to stderr -v, --version Print the version -h, --help Print this help diff --git a/index.ts b/index.ts index bd0c2da..92889f9 100755 --- a/index.ts +++ b/index.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import {SubprocessError, type Result, exec, reNewline, tomlGetString} from "./utils.ts"; +import {SubprocessError, type Result, colorize, exec, logVerbose, reNewline, setVerbose, tomlGetString} from "./utils.ts"; import {parseArgs} from "node:util"; import {basename, dirname, join, relative, resolve} from "node:path"; import {cwd, exit, stdout} from "node:process"; @@ -304,6 +304,7 @@ export async function createForgeRelease(repoInfo: RepoInfo, tagName: string, bo let lastError: Error | undefined; for (const token of tokens) { let response: Response; + logVerbose(`${colorize("POST", "magenta")} ${apiUrl}`); try { response = await fetch(apiUrl, { method: "POST", @@ -316,6 +317,7 @@ export async function createForgeRelease(repoInfo: RepoInfo, tagName: string, bo } catch (err: any) { throw new Error(`Failed to create release: ${err.cause?.message || err.message || "Unknown error"}`); } + logVerbose(`${colorize(String(response.status), response.ok ? "green" : "red")} ${apiUrl}`); if (response.ok) { const result = await response.json(); @@ -330,6 +332,7 @@ export async function createForgeRelease(repoInfo: RepoInfo, tagName: string, bo const errorText = await response.text(); lastError = new Error(`Failed to create release: ${response.status} ${response.statusText}\n${errorText}`); if (response.status !== 401 && response.status !== 403) throw lastError; + logVerbose(`auth failed (${response.status}), trying next token`); } throw lastError ?? new Error("No tokens provided"); } @@ -358,12 +361,15 @@ async function main(): Promise { replace: {short: "r", type: "string", multiple: true}, message: {short: "m", type: "string", multiple: true}, preid: {short: "i", type: "string"}, + verbose: {short: "V", type: "boolean"}, }, }); const args = result.values; let [level, ...files] = result.positionals; files = Array.from(new Set(files)); + setVerbose(Boolean(args.verbose)); + if (args.version) { console.info(pkg.version || "0.0.0"); end(); @@ -384,6 +390,7 @@ async function main(): Promise { -g, --gitless Do not perform any git action like creating commit and tag -D, --dry Do not create a tag or commit, just print what would be done -R, --release Create a GitHub or Gitea release, push commit and tag to origin + -V, --verbose Print verbose output to stderr -v, --version Print the version -h, --help Print this help @@ -419,6 +426,7 @@ async function main(): Promise { // obtain old version let baseVersion: string = ""; let cachedDescribeTag: string = ""; + let baseSource: string = ""; if (!args.base) { let stdout: string = ""; if (!args.gitless) { @@ -428,6 +436,7 @@ async function main(): Promise { cachedDescribeTag = result.stdout.trim(); if (isSemver(cachedDescribeTag)) { baseVersion = cachedDescribeTag.replace(reVersionPrefix, ""); + baseSource = "git describe"; } } catch {} // Fall back to full tag list if describe didn't yield a semver tag @@ -438,25 +447,33 @@ async function main(): Promise { for (const tag of stdout.split(reNewline).map(v => v.trim()).filter(Boolean)) { if (isSemver(tag)) { baseVersion = tag.replace(reVersionPrefix, ""); + baseSource = "git tag list"; break; } } } } if (!baseVersion) { - // Try to get version from package.json first, then pyproject.toml as fallback - // package.json takes precedence for JavaScript/TypeScript projects - baseVersion = readVersionFromPackageJson(projectRoot) || readVersionFromPyprojectToml(projectRoot) || ""; + baseVersion = readVersionFromPackageJson(projectRoot) || ""; + if (baseVersion) { + baseSource = "package.json"; + } else { + baseVersion = readVersionFromPyprojectToml(projectRoot) || ""; + if (baseVersion) baseSource = "pyproject.toml"; + } if (!baseVersion && args.gitless) { return end(new Error(`--gitless requires --base to be set or a version in package.json or pyproject.toml`)); } if (!baseVersion) { baseVersion = "0.0.0"; + baseSource = "default"; } } } else { baseVersion = String(args.base); + baseSource = "--base"; } + logVerbose(`base version ${baseVersion} from ${baseSource}`); // chop off "v" if (baseVersion.startsWith("v")) baseVersion = baseVersion.substring(1); @@ -479,6 +496,7 @@ async function main(): Promise { // set new version const newVersion = incrementSemver(baseVersion, level, typeof args.preid === "string" ? args.preid : undefined); + logVerbose(`new version ${newVersion}`); const replacements: Array<{re: RegExp, replacement: string}> = []; if (args.replace?.length) { @@ -531,16 +549,26 @@ async function main(): Promise { // update files for (const file of files) { const [filePath, newData] = getFileChanges({file, baseVersion, newVersion, replacements, date}); - if (newData !== null) write(filePath, newData); + if (newData !== null) { + logVerbose(`writing ${filePath}`); + write(filePath, newData); + } else { + logVerbose(`skipping ${file} (unhandled lockfile)`); + } } } if (typeof args.command === "string") { + logVerbose(`running command: ${args.command}`); writeResult(await exec(args.command, [], {shell: true})); } - if (args.gitless) return; // nothing else to do + if (args.gitless) { + logVerbose("gitless — skipping commit, tag, and release"); + return; + } if (args.dry) { + logVerbose("dry run — skipping commit and tag"); return console.info(`Would create new tag and commit: ${tagName}`); } @@ -582,6 +610,7 @@ async function main(): Promise { if (!tokens.length) { throw new Error(`${forgeName} release requested but no token found in environment`); } + logVerbose(`creating ${forgeName} release for ${tagName} (${tokens.length} token${tokens.length === 1 ? "" : "s"} to try)`); await createForgeRelease(repoInfo, tagName, releaseBody, tokens); } } diff --git a/utils.ts b/utils.ts index d40243d..68f10aa 100644 --- a/utils.ts +++ b/utils.ts @@ -1,7 +1,36 @@ import {execFile as execFileCb} from "node:child_process"; +import {stderr} from "node:process"; +import {styleText} from "node:util"; export type Result = {stdout: string; stderr: string}; +let verbose = false; +const useColor = stderr.isTTY; + +export function setVerbose(value: boolean): void { + verbose = value; +} + +const pad = (value: number, len = 2) => String(value).padStart(len, "0"); + +function timestamp(): string { + const date = new Date(); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`; +} + +export function logVerbose(message: string): void { + if (!verbose) return; + console.error(`${timestamp()} ${message}`); +} + +export function colorize(text: string, color: "magenta" | "green" | "red"): string { + return useColor ? styleText(color, text) : text; +} + +function quoteArg(arg: string): string { + return /[\s"']/.test(arg) ? JSON.stringify(arg) : arg; +} + export class SubprocessError extends Error { stdout: string; stderr: string; @@ -47,6 +76,7 @@ export function tomlGetString(content: string, section: string, key: string): st } export function exec(file: string, args: readonly string[], options?: ExecOptions): Promise { + if (verbose) logVerbose(`$ ${args.length ? `${file} ${args.map(quoteArg).join(" ")}` : file}`); return new Promise((resolve, reject) => { const child = execFileCb(file, args as string[], {encoding: "utf8", shell: options?.shell, windowsHide: true, cwd: options?.cwd, env: options?.env}, (error, stdout, stderr) => { if (error) {