From f421c5851d6d5b7f3b88a1047c3e601fefd0c537 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 10 May 2026 11:04:57 +0000 Subject: [PATCH] feat(cli): add toolchain management command --- changelog.md | 3 + readme.md | 18 ++ ts/gitzone.cli.ts | 8 + ts/mod_standard/index.ts | 13 + ts/mod_tools/classes.packagemanager.ts | 176 ++++++++++++ ts/mod_tools/index.ts | 359 +++++++++++++++++++++++++ ts/mod_tools/mod.plugins.ts | 1 + 7 files changed, 578 insertions(+) create mode 100644 ts/mod_tools/classes.packagemanager.ts create mode 100644 ts/mod_tools/index.ts create mode 100644 ts/mod_tools/mod.plugins.ts diff --git a/changelog.md b/changelog.md index 611f9a5..8d8844b 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,9 @@ ## Pending +### Features + +- Add `gitzone tools` for managing the global `@git.zone` toolchain from the main CLI. ## 2026-05-10 - 2.15.0 diff --git a/readme.md b/readme.md index 0a164a1..e0626bc 100644 --- a/readme.md +++ b/readme.md @@ -57,6 +57,7 @@ gitzone release | `format` | Plan or apply project formatting and standardization | | `config` | Inspect, update, and migrate `.smartconfig.json` | | `services` | Manage local MongoDB, MinIO, and Elasticsearch containers | +| `tools` | Manage the global `@git.zone` toolchain | | `template` | Scaffold projects from built-in templates | | `meta` | Manage multi-repository workspaces | | `open` | Open repository assets like CI pages | @@ -67,6 +68,23 @@ gitzone release Global flags include `--help`, `--json`, `--plain`, `--agent`, `--no-interactive`, and `--no-check-updates`. +## Toolchain Management + +`gitzone tools` replaces the former `gtools` command from `@git.zone/tools`. It manages globally installed `@git.zone` development tools through pnpm. + +```bash +# Check installed @git.zone tools and update outdated packages +gitzone tools update + +# Update without prompts +gitzone tools update -y + +# Install missing managed @git.zone tools +gitzone tools install +``` + +`gitzone tools update` checks `@git.zone/cli` first. If the CLI itself needs an update, it updates `@git.zone/cli` and asks you to rerun the command before updating the rest of the toolchain. + ## Commit Workflow `gitzone commit` creates one semantic source commit. It does not bump versions, create tags, publish packages, or push Docker images. diff --git a/ts/gitzone.cli.ts b/ts/gitzone.cli.ts index f420292..7b00379 100644 --- a/ts/gitzone.cli.ts +++ b/ts/gitzone.cli.ts @@ -137,6 +137,14 @@ export let run = async () => { modHelpers.run(argvArg); }); + /** + * manage the global @git.zone toolchain + */ + gitzoneSmartcli.addCommand("tools").subscribe(async (argvArg) => { + const modTools = await import("./mod_tools/index.js"); + await modTools.run(argvArg); + }); + /** * manage release configuration */ diff --git a/ts/mod_standard/index.ts b/ts/mod_standard/index.ts index ebe5312..df20e21 100644 --- a/ts/mod_standard/index.ts +++ b/ts/mod_standard/index.ts @@ -23,6 +23,7 @@ const commandSummaries: ICommandHelpSummary[] = [ { name: "format", description: "Plan or apply project formatting changes" }, { name: "config", description: "Read and change .smartconfig.json settings" }, { name: "services", description: "Manage or configure development services" }, + { name: "tools", description: "Manage the global @git.zone toolchain" }, { name: "template", description: "Create a project from a template" }, { name: "open", description: "Open project assets and CI pages" }, { name: "docker", description: "Run Docker-related maintenance tasks" }, @@ -75,6 +76,7 @@ export let run = async (argvArg: any = {}) => { { name: "Configure release settings", value: "config" }, { name: "Create from template", value: "template" }, { name: "Manage dev services (MongoDB, S3)", value: "services" }, + { name: "Manage global @git.zone tools", value: "tools" }, { name: "Open project assets", value: "open" }, { name: "Show help", value: "help" }, ], @@ -113,6 +115,11 @@ export let run = async (argvArg: any = {}) => { await modServices.run({ _: ["services"] }); break; } + case "tools": { + const modTools = await import("../mod_tools/index.js"); + await modTools.run({ _: ["tools"] }); + break; + } case "open": { const modOpen = await import("../mod_open/index.js"); await modOpen.run({ _: ["open"] }); @@ -196,6 +203,7 @@ export async function showHelp( console.log(" gitzone release --plan"); console.log(" gitzone format plan --json"); console.log(" gitzone services set mongodb,minio"); + console.log(" gitzone tools update"); console.log(""); console.log("Run gitzone --help for command-specific usage."); console.log(""); @@ -231,6 +239,11 @@ async function showCommandHelp( modServices.showHelp(mode); return true; } + case "tools": { + const modTools = await import("../mod_tools/index.js"); + modTools.showHelp(mode); + return true; + } default: return false; } diff --git a/ts/mod_tools/classes.packagemanager.ts b/ts/mod_tools/classes.packagemanager.ts new file mode 100644 index 0000000..809c70b --- /dev/null +++ b/ts/mod_tools/classes.packagemanager.ts @@ -0,0 +1,176 @@ +import * as plugins from "./mod.plugins.js"; + +export interface IInstalledPackage { + name: string; + version: string; +} + +export interface IPackageUpdateInfo { + name: string; + currentVersion: string; + latestVersion: string; + needsUpdate: boolean; +} + +export interface IPackageManagerInfo { + available: boolean; + currentVersion: string; + latestVersion: string | null; + needsUpdate: boolean; +} + +export class PackageManagerUtil { + private shell = new plugins.smartshell.Smartshell({ + executor: "bash", + }); + + public async detectPnpm(): Promise { + try { + const result = await this.shell.execSilent("pnpm --version 2>/dev/null"); + return result.exitCode === 0 && Boolean(result.stdout.trim()); + } catch { + return false; + } + } + + public async getPnpmVersionInfo(): Promise { + const available = await this.detectPnpm(); + if (!available) { + return { + available: false, + currentVersion: "unknown", + latestVersion: null, + needsUpdate: false, + }; + } + + const currentVersion = await this.getCurrentPnpmVersion(); + const latestVersion = await this.getLatestVersion("pnpm", ["https://registry.npmjs.org"]); + + return { + available: true, + currentVersion, + latestVersion, + needsUpdate: latestVersion ? this.isNewerVersion(currentVersion, latestVersion) : false, + }; + } + + public async getInstalledPackages(): Promise { + const packages: IInstalledPackage[] = []; + + try { + const result = await this.shell.execSilent("pnpm list -g --depth=0 --json 2>/dev/null || true"); + const output = result.stdout.trim(); + if (!output) { + return packages; + } + + const data = JSON.parse(output); + const dataArray = Array.isArray(data) ? data : [data]; + for (const item of dataArray) { + const dependencies = item.dependencies || {}; + for (const [name, info] of Object.entries(dependencies)) { + if (!name.startsWith("@git.zone/")) { + continue; + } + packages.push({ + name, + version: (info as any).version || "unknown", + }); + } + } + } catch { + return packages; + } + + return packages; + } + + public async getLatestVersion( + packageName: string, + registries = ["https://verdaccio.lossless.digital", "https://registry.npmjs.org"], + ): Promise { + for (const registry of registries) { + const latest = await this.getLatestVersionFromRegistry(registry, packageName); + if (latest) { + return latest; + } + } + return null; + } + + public async installLatest(packageName: string): Promise { + const packageSpecifier = `${packageName}@latest`; + console.log(` Installing ${packageSpecifier} via pnpm...`); + + try { + const result = await this.shell.exec(`pnpm add -g ${shellQuote(packageSpecifier)}`); + return result.exitCode === 0; + } catch { + return false; + } + } + + public isNewerVersion(current: string, latest: string): boolean { + const currentParts = normalizeSemver(current); + const latestParts = normalizeSemver(latest); + + for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) { + const currentPart = currentParts[i] || 0; + const latestPart = latestParts[i] || 0; + if (latestPart > currentPart) return true; + if (latestPart < currentPart) return false; + } + + return false; + } + + private async getCurrentPnpmVersion(): Promise { + try { + const result = await this.shell.execSilent("pnpm --version 2>/dev/null"); + const versionMatch = result.stdout.trim().match(/(\d+\.\d+\.\d+)/); + return versionMatch?.[1] || "unknown"; + } catch { + return "unknown"; + } + } + + private async getLatestVersionFromRegistry( + registry: string, + packageName: string, + ): Promise { + const encodedName = packageName.replace("/", "%2f"); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + + try { + const response = await fetch(`${registry}/${encodedName}`, { + signal: controller.signal, + headers: { + accept: "application/json", + }, + }); + if (!response.ok) { + return null; + } + const data = await response.json(); + const latest = (data as any)["dist-tags"]?.latest; + return typeof latest === "string" && latest.length > 0 ? latest : null; + } catch { + return null; + } finally { + clearTimeout(timeout); + } + } +} + +function normalizeSemver(version: string): number[] { + return version + .replace(/^[^\d]*/, "") + .split(".") + .map((part) => parseInt(part, 10) || 0); +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} diff --git a/ts/mod_tools/index.ts b/ts/mod_tools/index.ts new file mode 100644 index 0000000..84cf573 --- /dev/null +++ b/ts/mod_tools/index.ts @@ -0,0 +1,359 @@ +import * as plugins from "./mod.plugins.js"; +import { commitinfo } from "../00_commitinfo_data.js"; +import type { ICliMode } from "../helpers.climode.js"; +import { getCliMode, printJson } from "../helpers.climode.js"; +import { + PackageManagerUtil, + type IInstalledPackage, + type IPackageUpdateInfo, +} from "./classes.packagemanager.js"; + +export const GITZONE_PACKAGES = [ + "@git.zone/cli", + "@git.zone/tsdoc", + "@git.zone/tsbuild", + "@git.zone/tstest", + "@git.zone/tspublish", + "@git.zone/tsbundle", + "@git.zone/tsdocker", + "@git.zone/tsview", + "@git.zone/tswatch", + "@git.zone/tsrust", +]; + +export const run = async (argvArg: any = {}): Promise => { + const mode = await getCliMode(argvArg); + const command = argvArg._?.[1] || "help"; + + if (mode.help || command === "help") { + showHelp(mode); + return; + } + + switch (command) { + case "update": + await runUpdate(argvArg, mode); + break; + case "install": + await runInstall(argvArg, mode); + break; + default: + showHelp(mode); + break; + } +}; + +async function runUpdate(argvArg: any, mode: ICliMode): Promise { + const verbose = Boolean(argvArg.v || argvArg.verbose); + const pmUtil = new PackageManagerUtil(); + + console.log("Scanning for installed @git.zone packages...\n"); + + const pnpmInfo = await pmUtil.getPnpmVersionInfo(); + if (!pnpmInfo.available) { + console.log("pnpm is required for gitzone tools update, but it was not found."); + return; + } + + console.log("Package manager:\n"); + console.log(" Name Current Latest Status"); + console.log(" ----------------------------------------------"); + const latestPnpm = (pnpmInfo.latestVersion || "unknown").padEnd(12); + const pnpmStatus = pnpmInfo.latestVersion === null + ? "? Version unknown" + : pnpmInfo.needsUpdate + ? "Update available" + : "Up to date"; + console.log(` ${"pnpm".padEnd(9)}${pnpmInfo.currentVersion.padEnd(12)}${latestPnpm}${pnpmStatus}`); + console.log(""); + + if (verbose) { + console.log("Using pnpm as the supported global package manager.\n"); + } + + const selfUpdated = await handleSelfUpdate(pmUtil, mode); + if (selfUpdated) { + return; + } + + const installedPackages = await pmUtil.getInstalledPackages(); + const packageInfos = await getPackageUpdateInfos(pmUtil, installedPackages); + + if (packageInfos.length === 0) { + console.log("No managed @git.zone packages found installed globally."); + return; + } + + console.log("Installed @git.zone packages:\n"); + console.log(" Package Current Latest Status"); + console.log(" ------------------------------------------------------------"); + for (const packageInfo of packageInfos) { + const status = packageInfo.latestVersion === "unknown" + ? "? Version unknown" + : packageInfo.needsUpdate + ? "Update available" + : "Up to date"; + console.log( + ` ${packageInfo.name.padEnd(28)}${packageInfo.currentVersion.padEnd(12)}${packageInfo.latestVersion.padEnd(12)}${status}`, + ); + } + console.log(""); + + await printMissingPackages(pmUtil, installedPackages); + + const packagesToUpdate = packageInfos.filter((packageInfo) => packageInfo.needsUpdate); + if (packagesToUpdate.length === 0) { + console.log("All managed packages are up to date."); + return; + } + + console.log(`Found ${packagesToUpdate.length} package(s) with available updates.\n`); + + if (!mode.yes && !mode.interactive) { + console.log("Run gitzone tools update -y to update without prompts."); + return; + } + + let shouldUpdate = mode.yes; + if (!shouldUpdate) { + const interactInstance = new plugins.smartinteract.SmartInteract(); + const answer = await interactInstance.askQuestion({ + type: "confirm", + name: "confirmUpdate", + message: "Do you want to update these packages?", + default: true, + }); + shouldUpdate = answer.value === true; + } + + if (!shouldUpdate) { + console.log("Update cancelled."); + return; + } + + await installPackages(pmUtil, packagesToUpdate.map((packageInfo) => packageInfo.name), "updated"); +} + +async function runInstall(argvArg: any, mode: ICliMode): Promise { + const verbose = Boolean(argvArg.v || argvArg.verbose); + const pmUtil = new PackageManagerUtil(); + + console.log("Scanning for missing @git.zone packages...\n"); + + const pnpmAvailable = await pmUtil.detectPnpm(); + if (!pnpmAvailable) { + console.log("pnpm is required for gitzone tools install, but it was not found."); + return; + } + + if (verbose) { + console.log("Using pnpm as the supported global package manager.\n"); + } + + const installedPackages = await pmUtil.getInstalledPackages(); + const installedNames = new Set(installedPackages.map((packageInfo) => packageInfo.name)); + const missingPackages = GITZONE_PACKAGES.filter((packageName) => !installedNames.has(packageName)); + + if (missingPackages.length === 0) { + console.log("All managed @git.zone packages are already installed."); + return; + } + + console.log(`Found ${missingPackages.length} missing package(s).\n`); + + if (!mode.yes && !mode.interactive) { + await printPackageListWithLatest(pmUtil, missingPackages); + console.log("Run gitzone tools install -y to install all missing packages without prompts."); + return; + } + + let selectedPackages = missingPackages; + if (!mode.yes) { + const choicesWithVersions: Array<{ name: string; value: string }> = []; + for (const packageName of missingPackages) { + const latest = await pmUtil.getLatestVersion(packageName); + choicesWithVersions.push({ + name: `${packageName}${latest ? `@${latest}` : ""}`, + value: packageName, + }); + } + + const interactInstance = new plugins.smartinteract.SmartInteract(); + const answer = await interactInstance.askQuestion({ + type: "checkbox", + name: "packages", + message: "Select packages to install:", + default: missingPackages, + choices: choicesWithVersions, + }); + + selectedPackages = answer.value as string[]; + if (selectedPackages.length === 0) { + console.log("No packages selected. Nothing to install."); + return; + } + } + + await installPackages(pmUtil, selectedPackages, "installed"); +} + +async function handleSelfUpdate( + pmUtil: PackageManagerUtil, + mode: ICliMode, +): Promise { + console.log("Checking for gitzone self-update...\n"); + const currentVersion = commitinfo.version; + const latestVersion = await pmUtil.getLatestVersion("@git.zone/cli"); + + if (!latestVersion || !pmUtil.isNewerVersion(currentVersion, latestVersion)) { + console.log(` @git.zone/cli ${currentVersion} Up to date\n`); + return false; + } + + console.log(` @git.zone/cli ${currentVersion} -> ${latestVersion} Update available\n`); + + if (!mode.yes && !mode.interactive) { + console.log("Run gitzone tools update -y to update gitzone first."); + return true; + } + + let shouldUpdate = mode.yes; + if (!shouldUpdate) { + const interactInstance = new plugins.smartinteract.SmartInteract(); + const answer = await interactInstance.askQuestion({ + type: "confirm", + name: "confirmSelfUpdate", + message: "Do you want to update gitzone itself first?", + default: true, + }); + shouldUpdate = answer.value === true; + } + + if (!shouldUpdate) { + console.log("Skipping gitzone self-update.\n"); + return false; + } + + const success = await pmUtil.installLatest("@git.zone/cli"); + if (!success) { + console.log("\ngitzone self-update failed. Continuing with the current version.\n"); + return false; + } + + console.log("\ngitzone has been updated. Re-run gitzone tools update to check remaining packages."); + return true; +} + +async function getPackageUpdateInfos( + pmUtil: PackageManagerUtil, + installedPackages: IInstalledPackage[], +): Promise { + const packageInfos: IPackageUpdateInfo[] = []; + for (const installedPackage of installedPackages) { + if (!GITZONE_PACKAGES.includes(installedPackage.name)) { + continue; + } + const latestVersion = await pmUtil.getLatestVersion(installedPackage.name); + packageInfos.push({ + name: installedPackage.name, + currentVersion: installedPackage.version, + latestVersion: latestVersion || "unknown", + needsUpdate: latestVersion + ? pmUtil.isNewerVersion(installedPackage.version, latestVersion) + : false, + }); + } + return packageInfos; +} + +async function printMissingPackages( + pmUtil: PackageManagerUtil, + installedPackages: IInstalledPackage[], +): Promise { + const installedNames = new Set(installedPackages.map((packageInfo) => packageInfo.name)); + const missingPackages = GITZONE_PACKAGES.filter((packageName) => !installedNames.has(packageName)); + if (missingPackages.length === 0) { + return; + } + + console.log("Not installed (managed @git.zone packages):\n"); + await printPackageListWithLatest(pmUtil, missingPackages); + console.log("Run gitzone tools install to install missing packages.\n"); +} + +async function printPackageListWithLatest( + pmUtil: PackageManagerUtil, + packageNames: string[], +): Promise { + console.log(" Package Latest"); + console.log(" ----------------------------------------"); + for (const packageName of packageNames) { + const latest = await pmUtil.getLatestVersion(packageName); + console.log(` ${packageName.padEnd(28)} ${latest || "unknown"}`); + } + console.log(""); +} + +async function installPackages( + pmUtil: PackageManagerUtil, + packageNames: string[], + action: "installed" | "updated", +): Promise { + let successCount = 0; + let failCount = 0; + + for (const packageName of packageNames) { + const success = await pmUtil.installLatest(packageName); + if (success) { + console.log(` ${packageName} ${action} successfully`); + successCount++; + } else { + console.log(` ${packageName} failed`); + failCount++; + } + } + + console.log(""); + if (failCount === 0) { + console.log(`All ${successCount} package(s) ${action} successfully.`); + } else { + console.log(`${successCount} package(s) ${action}, ${failCount} failed.`); + } +} + +export function showHelp(mode?: ICliMode): void { + if (mode?.json) { + printJson({ + name: "gitzone tools", + usage: "gitzone tools [options]", + commands: [ + { name: "update", description: "Check and update globally installed @git.zone packages" }, + { name: "install", description: "Install missing managed @git.zone packages" }, + ], + flags: [ + { flag: "-y, --yes", description: "Run without confirmation prompts" }, + { flag: "-v, --verbose", description: "Show package manager diagnostics" }, + ], + packageManager: "pnpm", + managedPackages: GITZONE_PACKAGES, + }); + return; + } + + console.log(""); + console.log("Usage: gitzone tools [options]"); + console.log(""); + console.log("Commands:"); + console.log(" update Check and update globally installed @git.zone packages"); + console.log(" install Install missing managed @git.zone packages"); + console.log(""); + console.log("Options:"); + console.log(" -y, --yes Run without confirmation prompts"); + console.log(" -v, --verbose Show package manager diagnostics"); + console.log(""); + console.log("Examples:"); + console.log(" gitzone tools update"); + console.log(" gitzone tools update -y"); + console.log(" gitzone tools install"); + console.log(""); +} diff --git a/ts/mod_tools/mod.plugins.ts b/ts/mod_tools/mod.plugins.ts new file mode 100644 index 0000000..0391631 --- /dev/null +++ b/ts/mod_tools/mod.plugins.ts @@ -0,0 +1 @@ +export * from "../plugins.js";