From d96b220703a7107514a17dd6e7bd4d282f7a1732 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 10 May 2026 13:09:28 +0000 Subject: [PATCH] feat(config): add guided configuration flows --- changelog.md | 3 + readme.md | 10 +- ts/mod_config/index.ts | 1000 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 986 insertions(+), 27 deletions(-) diff --git a/changelog.md b/changelog.md index 8327359..275124e 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,9 @@ ## Pending +### Features + +- Add guided project, CLI, release, and doctor flows to `gitzone config`. ## 2026-05-10 - 2.16.1 diff --git a/readme.md b/readme.md index e0626bc..07afe21 100644 --- a/readme.md +++ b/readme.md @@ -258,6 +258,14 @@ Useful config commands: # Show current @git.zone/cli config gitzone config show --json +# Configure project basics, CLI behavior, and release targets interactively +gitzone config project +gitzone config cli +gitzone config release + +# Validate schema, legacy keys, release targets, registries, and npm auth +gitzone config doctor + # Read the npm release target registries gitzone config get release.targets.npm.registries @@ -404,7 +412,7 @@ gitzone config show --json ## License and Legal Information -This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./license) file. +This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file. **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file. diff --git a/ts/mod_config/index.ts b/ts/mod_config/index.ts index 764a9c6..8121982 100644 --- a/ts/mod_config/index.ts +++ b/ts/mod_config/index.ts @@ -7,7 +7,9 @@ import { runFormatter, type ICheckResult } from "../mod_format/index.js"; import type { ICliMode } from "../helpers.climode.js"; import { getCliMode, printJson } from "../helpers.climode.js"; import { + CLI_NAMESPACE, getCliConfigValueFromData, + getSmartconfigPath, readSmartconfigFile, setCliConfigValueInData, unsetCliConfigValueInData, @@ -100,9 +102,21 @@ export const run = async (argvArg: any) => { case "commit": await handleCommit(argvArg._?.[2], argvArg._?.[3], mode); break; + case "project": + await handleProject(mode); + break; + case "cli": + await handleCli(mode); + break; + case "release": + await handleRelease(mode); + break; case "services": await handleServices(mode); break; + case "doctor": + await handleDoctor(mode); + break; case "migrate": await handleMigrate(value, mode); break; @@ -145,13 +159,17 @@ async function handleInteractiveMenu(): Promise { default: "show", choices: [ { name: "Show current configuration", value: "show" }, + { name: "Configure project basics", value: "project" }, + { name: "Configure CLI behavior", value: "cli" }, + { name: "Configure commit workflow", value: "commit" }, + { name: "Configure release workflow", value: "release" }, + { name: "Configure services", value: "services" }, + { name: "Validate configuration (doctor)", value: "doctor" }, { name: "Add an npm target registry", value: "add" }, { name: "Remove an npm target registry", value: "remove" }, { name: "Clear npm target registries", value: "clear" }, { name: "Set access level (public/private)", value: "access" }, { name: "Migrate smartconfig schema", value: "migrate" }, - { name: "Configure commit options", value: "commit" }, - { name: "Configure services", value: "services" }, { name: "Show help", value: "help" }, ], }); @@ -162,6 +180,15 @@ async function handleInteractiveMenu(): Promise { case "show": await handleShow(defaultCliMode); break; + case "project": + await handleProject(defaultCliMode); + break; + case "cli": + await handleCli(defaultCliMode); + break; + case "release": + await handleRelease(defaultCliMode); + break; case "add": await handleAdd(undefined, defaultCliMode); break; @@ -183,6 +210,9 @@ async function handleInteractiveMenu(): Promise { case "services": await handleServices(defaultCliMode); break; + case "doctor": + await handleDoctor(defaultCliMode); + break; case "help": showHelp(); break; @@ -190,48 +220,80 @@ async function handleInteractiveMenu(): Promise { } /** - * Show current registry configuration + * Show current CLI project configuration */ async function handleShow(mode: ICliMode): Promise { + const smartconfigData = await readSmartconfigFile(); + const cliConfig = getCliConfigValueFromData(smartconfigData, "") || {}; + if (mode.json) { - const smartconfigData = await readSmartconfigFile(); - printJson(getCliConfigValueFromData(smartconfigData, "")); + printJson(cliConfig); return; } - const config = await ReleaseConfig.fromCwd(); - const registries = config.getRegistries(); - const accessLevel = config.getAccessLevel(); - console.log(""); console.log( "╭─────────────────────────────────────────────────────────────╮", ); console.log( - "│ Release NPM Target Configuration │", + "│ gitzone config - Project Configuration │", ); console.log( "╰─────────────────────────────────────────────────────────────╯", ); console.log(""); - // Show access level - plugins.logger.log("info", `Access Level: ${accessLevel}`); - console.log(""); - - if (registries.length === 0) { - plugins.logger.log("info", "No npm target registries configured."); - console.log(""); - console.log(" Run `gitzone config add ` to add one."); - console.log(""); - } else { - plugins.logger.log("info", `Configured npm target registries (${registries.length}):`); - console.log(""); - registries.forEach((url, index) => { - console.log(` ${index + 1}. ${url}`); - }); + if (Object.keys(cliConfig).length === 0) { + plugins.logger.log("warn", `No ${CLI_NAMESPACE} configuration found.`); + console.log(" Run `gitzone config project` to create project basics."); console.log(""); + return; } + + printConfigSection("Project", [ + ["schemaVersion", formatValue(cliConfig.schemaVersion)], + ["projectType", formatValue(cliConfig.projectType)], + ["repository", formatRepository(cliConfig.module)], + ["description", formatValue(cliConfig.module?.description)], + ["npm package", formatValue(cliConfig.module?.npmPackagename || cliConfig.module?.npmPackageName)], + ["license", formatValue(cliConfig.module?.license)], + ["keywords", formatList(cliConfig.module?.keywords)], + ]); + + printConfigSection("CLI Behavior", [ + ["interactive", formatValue(cliConfig.cli?.interactive)], + ["output", formatValue(cliConfig.cli?.output)], + ["checkUpdates", formatValue(cliConfig.cli?.checkUpdates)], + ]); + + printConfigSection("Commit Workflow", [ + ["confirmation", formatValue(cliConfig.commit?.confirmation)], + ["steps", formatList(cliConfig.commit?.steps)], + ["test command", formatValue(cliConfig.commit?.test?.command)], + ["build command", formatValue(cliConfig.commit?.build?.command)], + ["push remote", formatValue(cliConfig.commit?.push?.remote)], + ["push followTags", formatValue(cliConfig.commit?.push?.followTags)], + ]); + + const release = cliConfig.release || {}; + const targets = release.targets || {}; + printConfigSection("Release Workflow", [ + ["confirmation", formatValue(release.confirmation)], + ["require clean tree", formatValue(release.preflight?.requireCleanTree)], + ["run tests", formatValue(release.preflight?.test)], + ["run build", formatValue(release.preflight?.build)], + ["test command", formatValue(release.preflight?.testCommand)], + ["build command", formatValue(release.preflight?.buildCommand)], + ]); + + printConfigSection("Release Targets", [ + ["git", formatTarget(targets.git?.enabled, targets.git)], + ["npm", formatTarget(targets.npm?.enabled, targets.npm)], + ["docker", formatTarget(targets.docker?.enabled, targets.docker)], + ]); + + console.log("Run `gitzone config doctor` to validate this configuration."); + console.log(""); } /** @@ -251,7 +313,7 @@ async function handleAdd( const response = await interactInstance.askQuestion({ type: "input", name: "registryUrl", - message: "Enter npm target registry URL:", + message: "Enter npm target registry URL:", default: "https://registry.npmjs.org", validate: (input: string) => { return !!(input && input.trim() !== ""); @@ -547,6 +609,358 @@ function showCommitHelp(): void { console.log(""); } +async function handleProject(mode: ICliMode): Promise { + if (!mode.interactive) { + throw new Error("Project configuration requires interactive mode. Use `gitzone config set` for automation."); + } + + const smartconfigData = await readSmartconfigFile(); + const cliConfig = getCliConfigValueFromData(smartconfigData, "") || {}; + const moduleConfig = cliConfig.module || {}; + const packageJson = await readPackageJson(); + const interactInstance = new plugins.smartinteract.SmartInteract(); + + const projectType = await askValue(interactInstance, { + type: "list", + name: "projectType", + message: "What kind of project is this?", + choices: ["npm", "service", "wcc", "website"], + default: cliConfig.projectType || "npm", + }); + const githost = await askValue(interactInstance, { + type: "input", + name: "githost", + message: "Git host:", + default: moduleConfig.githost || "code.foss.global", + }); + const gitscope = await askValue(interactInstance, { + type: "input", + name: "gitscope", + message: "Git scope/owner:", + default: moduleConfig.gitscope || "git.zone", + }); + const gitrepo = await askValue(interactInstance, { + type: "input", + name: "gitrepo", + message: "Git repository name:", + default: moduleConfig.gitrepo || inferRepoName(packageJson.name), + }); + const description = await askValue(interactInstance, { + type: "input", + name: "description", + message: "Project description:", + default: moduleConfig.description || packageJson.description || "", + }); + const npmPackagename = await askValue(interactInstance, { + type: "input", + name: "npmPackagename", + message: "npm package name:", + default: moduleConfig.npmPackagename || moduleConfig.npmPackageName || packageJson.name || "", + }); + const license = await askValue(interactInstance, { + type: "input", + name: "license", + message: "License:", + default: moduleConfig.license || packageJson.license || "MIT", + }); + const keywords = await askValue(interactInstance, { + type: "input", + name: "keywords", + message: "Keywords (comma-separated):", + default: Array.isArray(moduleConfig.keywords) + ? moduleConfig.keywords.join(", ") + : Array.isArray(packageJson.keywords) + ? packageJson.keywords.join(", ") + : "", + }); + + setCliConfigValueInData(smartconfigData, "schemaVersion", CURRENT_GITZONE_CLI_SCHEMA_VERSION); + setCliConfigValueInData(smartconfigData, "projectType", projectType); + setCliConfigValueInData(smartconfigData, "module", { + ...moduleConfig, + githost: githost.trim(), + gitscope: gitscope.trim(), + gitrepo: gitrepo.trim(), + description: description.trim(), + npmPackagename: npmPackagename.trim(), + license: license.trim(), + keywords: parseCsv(keywords), + }); + await writeSmartconfigFile(smartconfigData); + plugins.logger.log("success", "Project configuration updated"); + await formatSmartconfigWithDiff(mode); +} + +async function handleCli(mode: ICliMode): Promise { + if (!mode.interactive) { + throw new Error("CLI behavior configuration requires interactive mode. Use `gitzone config set` for automation."); + } + + const smartconfigData = await readSmartconfigFile(); + const cliConfig = getCliConfigValueFromData(smartconfigData, "cli") || {}; + const interactInstance = new plugins.smartinteract.SmartInteract(); + + const output = await askValue(interactInstance, { + type: "list", + name: "output", + message: "Default output mode:", + choices: ["human", "plain", "json"], + default: cliConfig.output || "human", + }); + const interactive = await askValue(interactInstance, { + type: "confirm", + name: "interactive", + message: "Enable interactive prompts by default?", + default: cliConfig.interactive ?? true, + }); + const checkUpdates = await askValue(interactInstance, { + type: "confirm", + name: "checkUpdates", + message: "Check for gitzone updates in human mode?", + default: cliConfig.checkUpdates ?? true, + }); + + setCliConfigValueInData(smartconfigData, "schemaVersion", CURRENT_GITZONE_CLI_SCHEMA_VERSION); + setCliConfigValueInData(smartconfigData, "cli", { + output, + interactive, + checkUpdates, + }); + await writeSmartconfigFile(smartconfigData); + plugins.logger.log("success", "CLI behavior configuration updated"); + await formatSmartconfigWithDiff(mode); +} + +async function handleRelease(mode: ICliMode): Promise { + if (!mode.interactive) { + throw new Error("Release configuration requires interactive mode. Use `gitzone config set` for automation."); + } + + const smartconfigData = await readSmartconfigFile(); + const currentRelease = getCliConfigValueFromData(smartconfigData, "release") || {}; + const currentTargets = currentRelease.targets || {}; + const interactInstance = new plugins.smartinteract.SmartInteract(); + + const confirmation = await askValue(interactInstance, { + type: "list", + name: "confirmation", + message: "Release confirmation mode:", + choices: ["prompt", "auto", "plan"], + default: currentRelease.confirmation || "prompt", + }); + const requireCleanTree = await askValue(interactInstance, { + type: "confirm", + name: "requireCleanTree", + message: "Require a clean git tree before release?", + default: currentRelease.preflight?.requireCleanTree ?? true, + }); + const runTests = await askValue(interactInstance, { + type: "confirm", + name: "runTests", + message: "Run tests during release preflight?", + default: currentRelease.preflight?.test ?? false, + }); + const runBuild = await askValue(interactInstance, { + type: "confirm", + name: "runBuild", + message: "Run build during release?", + default: currentRelease.preflight?.build ?? true, + }); + const testCommand = await askValue(interactInstance, { + type: "input", + name: "testCommand", + message: "Release test command:", + default: currentRelease.preflight?.testCommand || "pnpm test", + }); + const buildCommand = await askValue(interactInstance, { + type: "input", + name: "buildCommand", + message: "Release build command:", + default: currentRelease.preflight?.buildCommand || "pnpm build", + }); + + const enabledTargets = await askValue(interactInstance, { + type: "checkbox", + name: "targets", + message: "Enable release targets:", + choices: [ + { name: "git - push branch and tags", value: "git" }, + { name: "npm - publish package registries", value: "npm" }, + { name: "docker - build and push images", value: "docker" }, + ], + default: getDefaultEnabledTargets(currentTargets), + }); + + const releaseTargets: Record = { ...currentTargets }; + + if (enabledTargets.includes("git")) { + releaseTargets.git = { + ...(currentTargets.git || {}), + enabled: true, + remote: await askValue(interactInstance, { + type: "input", + name: "gitRemote", + message: "Git remote:", + default: currentTargets.git?.remote || "origin", + }), + pushBranch: await askValue(interactInstance, { + type: "confirm", + name: "pushBranch", + message: "Push release commit branch?", + default: currentTargets.git?.pushBranch ?? true, + }), + pushTags: await askValue(interactInstance, { + type: "confirm", + name: "pushTags", + message: "Push release tags?", + default: currentTargets.git?.pushTags ?? true, + }), + }; + } else { + releaseTargets.git = { ...(currentTargets.git || {}), enabled: false }; + } + + if (enabledTargets.includes("npm")) { + const registries = await askValue(interactInstance, { + type: "input", + name: "npmRegistries", + message: "npm registries (comma-separated):", + default: Array.isArray(currentTargets.npm?.registries) + ? currentTargets.npm.registries.join(", ") + : "https://registry.npmjs.org", + }); + releaseTargets.npm = { + ...(currentTargets.npm || {}), + enabled: true, + registries: parseCsv(registries).map(normalizeRegistryUrl), + accessLevel: await askValue(interactInstance, { + type: "list", + name: "npmAccessLevel", + message: "npm publish access level:", + choices: ["public", "private"], + default: currentTargets.npm?.accessLevel || "public", + }), + alreadyPublished: await askValue(interactInstance, { + type: "list", + name: "alreadyPublished", + message: "When a package version is already published:", + choices: ["success", "error"], + default: currentTargets.npm?.alreadyPublished || "success", + }), + }; + } else { + releaseTargets.npm = { ...(currentTargets.npm || {}), enabled: false }; + } + + if (enabledTargets.includes("docker")) { + const images = await askValue(interactInstance, { + type: "input", + name: "dockerImages", + message: "Docker image templates (comma-separated, supports {{version}}):", + default: Array.isArray(currentTargets.docker?.images) + ? currentTargets.docker.images.join(", ") + : "", + }); + releaseTargets.docker = { + ...(currentTargets.docker || {}), + enabled: true, + images: parseCsv(images), + }; + } else { + releaseTargets.docker = { ...(currentTargets.docker || {}), enabled: false }; + } + + setCliConfigValueInData(smartconfigData, "schemaVersion", CURRENT_GITZONE_CLI_SCHEMA_VERSION); + setCliConfigValueInData(smartconfigData, "release", { + ...currentRelease, + confirmation, + preflight: { + ...(currentRelease.preflight || {}), + requireCleanTree, + test: runTests, + build: runBuild, + testCommand: testCommand.trim(), + buildCommand: buildCommand.trim(), + }, + targets: releaseTargets, + }); + await writeSmartconfigFile(smartconfigData); + plugins.logger.log("success", "Release configuration updated"); + await formatSmartconfigWithDiff(mode); +} + +async function handleDoctor(mode: ICliMode): Promise { + const findings: IDoctorFinding[] = []; + const smartconfigPath = getSmartconfigPath(); + const smartconfigExists = await plugins.smartfs.file(smartconfigPath).exists(); + + if (!smartconfigExists) { + findings.push({ + level: "warn", + message: ".smartconfig.json does not exist", + fix: "Run `gitzone config project` to create project basics.", + }); + return printDoctorResult(findings, mode); + } + + let smartconfigData: Record; + try { + smartconfigData = await readSmartconfigFile(); + } catch (error) { + findings.push({ + level: "error", + message: ".smartconfig.json is not valid JSON", + fix: error instanceof Error ? error.message : String(error), + }); + return printDoctorResult(findings, mode); + } + + const cliConfig = getCliConfigValueFromData(smartconfigData, "") || {}; + if (Object.keys(cliConfig).length === 0) { + findings.push({ + level: "error", + message: `${CLI_NAMESPACE} configuration is missing`, + fix: "Run `gitzone config project` or `gitzone config migrate`.", + }); + } + + for (const legacyNamespace of ["gitzone", "tsdoc", "npmdocker", "npmci", "szci"]) { + if (smartconfigData[legacyNamespace]) { + findings.push({ + level: "warn", + message: `Legacy namespace '${legacyNamespace}' is present`, + fix: "Run `gitzone config migrate`.", + }); + } + } + + if (cliConfig.schemaVersion === CURRENT_GITZONE_CLI_SCHEMA_VERSION) { + findings.push({ level: "ok", message: `Schema version is current (${CURRENT_GITZONE_CLI_SCHEMA_VERSION})` }); + } else { + findings.push({ + level: "warn", + message: `Schema version is ${formatValue(cliConfig.schemaVersion)}, expected ${CURRENT_GITZONE_CLI_SCHEMA_VERSION}`, + fix: "Run `gitzone config migrate`.", + }); + } + + if (["npm", "service", "wcc", "website"].includes(cliConfig.projectType)) { + findings.push({ level: "ok", message: `Project type is ${cliConfig.projectType}` }); + } else { + findings.push({ + level: "warn", + message: `Project type is missing or invalid: ${formatValue(cliConfig.projectType)}`, + fix: "Run `gitzone config project`.", + }); + } + await validateDetectedProjectType(cliConfig, findings); + + validateCommitConfig(cliConfig.commit || {}, findings); + await validateReleaseConfig(cliConfig.release || {}, findings); + + printDoctorResult(findings, mode); +} + /** * Handle services configuration */ @@ -715,6 +1129,526 @@ function parseConfigValue(rawValue: string): any { return rawValue; } +type TDoctorFindingLevel = "ok" | "warn" | "error"; + +interface IDoctorFinding { + level: TDoctorFindingLevel; + message: string; + fix?: string; +} + +const validProjectTypes = ["npm", "service", "wcc", "website"]; +const validConfirmationModes = ["prompt", "auto", "plan"]; +const validCommitSteps = [ + "format", + "analyze", + "test", + "build", + "changelog", + "commit", + "push", +]; + +function printConfigSection( + title: string, + rows: Array<[string, string]>, +): void { + console.log(`${title}:`); + for (const [label, value] of rows) { + console.log(` ${label.padEnd(20)} ${value}`); + } + console.log(""); +} + +function formatValue(value: unknown): string { + if (value === undefined || value === null || value === "") { + return "(unset)"; + } + if (typeof value === "boolean") { + return value ? "true" : "false"; + } + if (typeof value === "string" || typeof value === "number") { + return String(value); + } + return JSON.stringify(value); +} + +function formatRepository(moduleConfig: any): string { + if (!moduleConfig) { + return "(unset)"; + } + const parts = [ + moduleConfig.githost, + moduleConfig.gitscope, + moduleConfig.gitrepo, + ].filter(Boolean); + return parts.length > 0 ? parts.join("/") : "(unset)"; +} + +function formatList(value: unknown): string { + if (!Array.isArray(value) || value.length === 0) { + return "(none)"; + } + return value.map((item) => String(item)).join(", "); +} + +function formatTarget(enabled: unknown, targetConfig: any): string { + const state = enabled === false ? "disabled" : "enabled"; + if (!targetConfig || Object.keys(targetConfig).length === 0) { + return state; + } + + const details: string[] = []; + if (targetConfig.remote) details.push(`remote=${targetConfig.remote}`); + if (Array.isArray(targetConfig.registries)) { + details.push(`registries=${targetConfig.registries.length}`); + } + if (targetConfig.accessLevel) details.push(`access=${targetConfig.accessLevel}`); + if (Array.isArray(targetConfig.images)) { + details.push(`images=${targetConfig.images.length}`); + } + return details.length > 0 ? `${state} (${details.join(", ")})` : state; +} + +async function askValue( + interactInstance: any, + options: any, +): Promise { + const response = await interactInstance.askQuestion(options); + return response.value as T; +} + +async function readPackageJson(): Promise> { + const packageJsonPath = plugins.path.join(process.cwd(), "package.json"); + if (!(await plugins.smartfs.file(packageJsonPath).exists())) { + return {}; + } + const content = (await plugins.smartfs + .file(packageJsonPath) + .encoding("utf8") + .read()) as string; + return JSON.parse(content); +} + +function inferRepoName(packageName: unknown): string { + if (typeof packageName === "string" && packageName.trim()) { + const normalizedName = packageName.trim(); + return normalizedName.includes("/") + ? normalizedName.split("/").pop() || normalizedName + : normalizedName; + } + return plugins.path.basename(process.cwd()); +} + +function parseCsv(value: string): string[] { + const result: string[] = []; + for (const item of value.split(",")) { + const trimmedItem = item.trim(); + if (trimmedItem && !result.includes(trimmedItem)) { + result.push(trimmedItem); + } + } + return result; +} + +function normalizeRegistryUrl(url: string): string { + let normalizedUrl = url.trim(); + if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) { + normalizedUrl = `https://${normalizedUrl}`; + } + return normalizedUrl.endsWith("/") ? normalizedUrl.slice(0, -1) : normalizedUrl; +} + +function getDefaultEnabledTargets(currentTargets: Record): string[] { + const enabledTargets: string[] = []; + const npmRegistries = currentTargets.npm?.registries; + if (currentTargets.git?.enabled ?? true) { + enabledTargets.push("git"); + } + if (currentTargets.npm?.enabled ?? (Array.isArray(npmRegistries) && npmRegistries.length > 0)) { + enabledTargets.push("npm"); + } + if (currentTargets.docker?.enabled ?? false) { + enabledTargets.push("docker"); + } + return enabledTargets; +} + +function printDoctorResult(findings: IDoctorFinding[], mode: ICliMode): void { + const counts = findings.reduce( + (accumulator, finding) => { + accumulator[finding.level] += 1; + return accumulator; + }, + { ok: 0, warn: 0, error: 0 } as Record, + ); + + if (mode.json) { + printJson({ + ok: counts.error === 0, + counts, + findings, + }); + } else { + console.log(""); + console.log("gitzone config doctor"); + console.log(""); + for (const finding of findings) { + const prefix = finding.level.toUpperCase().padEnd(5); + console.log(`${prefix} ${finding.message}`); + if (finding.fix) { + console.log(` ${finding.fix}`); + } + } + console.log(""); + console.log(`Summary: ${counts.ok} ok, ${counts.warn} warning, ${counts.error} error`); + console.log(""); + } + + if (counts.error > 0) { + process.exitCode = 1; + } +} + +function validateCommitConfig( + commitConfig: Record, + findings: IDoctorFinding[], +): void { + const confirmation = commitConfig.confirmation; + if (confirmation === undefined || validConfirmationModes.includes(confirmation)) { + findings.push({ level: "ok", message: "Commit confirmation mode is valid" }); + } else { + findings.push({ + level: "warn", + message: `Invalid commit confirmation mode: ${formatValue(confirmation)}`, + fix: "Use prompt, auto, or plan.", + }); + } + + const steps = commitConfig.steps; + if (steps === undefined) { + findings.push({ level: "ok", message: "Commit workflow uses default steps" }); + return; + } + if (!Array.isArray(steps) || steps.length === 0) { + findings.push({ + level: "error", + message: "Commit steps must be a non-empty array", + fix: "Run `gitzone config commit` or unset commit.steps.", + }); + return; + } + + const invalidSteps = steps.filter((step) => !validCommitSteps.includes(step)); + if (invalidSteps.length > 0) { + findings.push({ + level: "error", + message: `Invalid commit steps: ${invalidSteps.join(", ")}`, + fix: `Allowed steps: ${validCommitSteps.join(", ")}`, + }); + } + + const analyzeIndex = steps.indexOf("analyze"); + const changelogIndex = steps.indexOf("changelog"); + const commitIndex = steps.indexOf("commit"); + if (analyzeIndex === -1 || changelogIndex === -1 || commitIndex === -1) { + findings.push({ + level: "error", + message: "Commit workflow must include analyze, changelog, and commit", + fix: "Run `gitzone config commit` or reset commit.steps.", + }); + } else if (analyzeIndex > commitIndex || changelogIndex > commitIndex) { + findings.push({ + level: "error", + message: "Commit workflow must run analyze and changelog before commit", + fix: "Move analyze and changelog before commit in commit.steps.", + }); + } else { + findings.push({ level: "ok", message: "Commit workflow steps are valid" }); + } + + if (steps.includes("test") && commitConfig.test?.command === "") { + findings.push({ + level: "warn", + message: "Commit test step has an empty command", + fix: "Set commit.test.command or unset it to use the default pnpm test.", + }); + } + if (steps.includes("build") && commitConfig.build?.command === "") { + findings.push({ + level: "warn", + message: "Commit build step has an empty command", + fix: "Set commit.build.command or unset it to use the default pnpm build.", + }); + } +} + +async function validateReleaseConfig( + releaseConfig: Record, + findings: IDoctorFinding[], +): Promise { + const confirmation = releaseConfig.confirmation; + if (confirmation === undefined || validConfirmationModes.includes(confirmation)) { + findings.push({ level: "ok", message: "Release confirmation mode is valid" }); + } else { + findings.push({ + level: "warn", + message: `Invalid release confirmation mode: ${formatValue(confirmation)}`, + fix: "Use prompt, auto, or plan.", + }); + } + + if (releaseConfig.registries || releaseConfig.accessLevel || releaseConfig.steps || releaseConfig.changelog) { + findings.push({ + level: "warn", + message: "Legacy release keys are present outside release.targets", + fix: "Run `gitzone config migrate`.", + }); + } + + const preflight = releaseConfig.preflight || {}; + if (preflight.test === true && preflight.testCommand === "") { + findings.push({ + level: "warn", + message: "Release test preflight has an empty command", + fix: "Set release.preflight.testCommand or unset it to use pnpm test.", + }); + } + if (preflight.build === true && preflight.buildCommand === "") { + findings.push({ + level: "warn", + message: "Release build preflight has an empty command", + fix: "Set release.preflight.buildCommand or unset it to use pnpm build.", + }); + } + + const targets = releaseConfig.targets || {}; + await validateGitTarget(targets.git || {}, findings); + await validateNpmTarget(targets.npm || {}, findings); + validateDockerTarget(targets.docker || {}, findings); +} + +async function validateGitTarget( + gitTarget: Record, + findings: IDoctorFinding[], +): Promise { + const enabled = gitTarget.enabled ?? true; + if (!enabled) { + findings.push({ level: "ok", message: "Git release target is disabled" }); + return; + } + + if (gitTarget.remote === "") { + findings.push({ + level: "error", + message: "Git release target remote is empty", + fix: "Set release.targets.git.remote or unset it to use origin.", + }); + return; + } + findings.push({ + level: "ok", + message: `Git release target is enabled (${gitTarget.remote || "origin"})`, + }); +} + +async function validateNpmTarget( + npmTarget: Record, + findings: IDoctorFinding[], +): Promise { + const registries = Array.isArray(npmTarget.registries) ? npmTarget.registries : []; + const enabled = npmTarget.enabled ?? registries.length > 0; + if (!enabled) { + findings.push({ level: "ok", message: "npm release target is disabled" }); + return; + } + + if (registries.length === 0) { + findings.push({ + level: "error", + message: "npm release target is enabled without registries", + fix: "Run `gitzone config add https://registry.npmjs.org` or disable release.targets.npm.enabled.", + }); + return; + } + + if (npmTarget.accessLevel && !["public", "private"].includes(npmTarget.accessLevel)) { + findings.push({ + level: "error", + message: `Invalid npm access level: ${npmTarget.accessLevel}`, + fix: "Use public or private.", + }); + } + if (npmTarget.alreadyPublished && !["success", "error"].includes(npmTarget.alreadyPublished)) { + findings.push({ + level: "error", + message: `Invalid npm alreadyPublished behavior: ${npmTarget.alreadyPublished}`, + fix: "Use success or error.", + }); + } + + const smartNetwork = new plugins.smartnetwork.SmartNetwork(); + await Promise.all( + registries.map(async (registry) => { + await validateNpmRegistry(registry, smartNetwork, findings); + await validateNpmAuth(registry, findings); + }), + ); +} + +async function validateNpmRegistry( + registry: string, + smartNetwork: plugins.smartnetwork.SmartNetwork, + findings: IDoctorFinding[], +): Promise { + const normalizedRegistry = normalizeRegistryUrl(registry); + if (normalizedRegistry !== registry) { + findings.push({ + level: "warn", + message: `npm registry should be normalized: ${registry}`, + fix: `Use ${normalizedRegistry}`, + }); + } + + let registryUrl: URL; + try { + registryUrl = new URL(normalizedRegistry); + } catch { + findings.push({ + level: "error", + message: `Invalid npm registry URL: ${registry}`, + fix: "Use a valid http or https registry URL.", + }); + return; + } + + if (registryUrl.protocol !== "https:" && registryUrl.protocol !== "http:") { + findings.push({ + level: "error", + message: `Unsupported npm registry protocol: ${registryUrl.protocol}`, + fix: "Use an http or https registry URL.", + }); + return; + } + + try { + const result = await smartNetwork.checkEndpoint(normalizedRegistry, { timeout: 5000 }); + if (result.status >= 200 && result.status < 500) { + findings.push({ + level: "ok", + message: `npm registry is reachable: ${normalizedRegistry} (${result.status})`, + }); + } else { + findings.push({ + level: "warn", + message: `npm registry returned status ${result.status}: ${normalizedRegistry}`, + }); + } + } catch (error) { + findings.push({ + level: "warn", + message: `npm registry is not reachable: ${normalizedRegistry}`, + fix: error instanceof Error ? error.message : String(error), + }); + } +} + +async function validateNpmAuth( + registry: string, + findings: IDoctorFinding[], +): Promise { + const normalizedRegistry = normalizeRegistryUrl(registry); + const smartshellInstance = new plugins.smartshell.Smartshell({ + executor: "bash", + sourceFilePaths: [], + }); + try { + const result = await smartshellInstance.execSpawn( + "pnpm", + ["npm", "whoami", `--registry=${normalizedRegistry}`], + { silent: true, timeout: 8000 }, + ); + if (result.exitCode === 0) { + findings.push({ + level: "ok", + message: `npm auth is available for ${normalizedRegistry}`, + }); + } else { + findings.push({ + level: "warn", + message: `npm auth is missing or invalid for ${normalizedRegistry}`, + fix: `Run pnpm npm login --registry=${normalizedRegistry}`, + }); + } + } catch (error) { + findings.push({ + level: "warn", + message: `Could not check npm auth for ${normalizedRegistry}`, + fix: error instanceof Error ? error.message : String(error), + }); + } +} + +function validateDockerTarget( + dockerTarget: Record, + findings: IDoctorFinding[], +): void { + const enabled = dockerTarget.enabled ?? false; + if (!enabled) { + findings.push({ level: "ok", message: "Docker release target is disabled" }); + return; + } + + if (!Array.isArray(dockerTarget.images) || dockerTarget.images.length === 0) { + findings.push({ + level: "error", + message: "Docker release target is enabled without images", + fix: "Set release.targets.docker.images or disable release.targets.docker.enabled.", + }); + return; + } + + findings.push({ + level: "ok", + message: `Docker release target has ${dockerTarget.images.length} image template(s)`, + }); +} + +async function validateDetectedProjectType( + cliConfig: Record, + findings: IDoctorFinding[], +): Promise { + const packageJsonPath = plugins.path.join(process.cwd(), "package.json"); + const denoJsonPath = plugins.path.join(process.cwd(), "deno.json"); + const hasPackageJson = await plugins.smartfs.file(packageJsonPath).exists(); + const hasDenoJson = await plugins.smartfs.file(denoJsonPath).exists(); + + if (!hasPackageJson && !hasDenoJson) { + findings.push({ + level: "warn", + message: "Could not detect package.json or deno.json", + fix: "Run this command from a project root.", + }); + return; + } + + if (hasPackageJson && validProjectTypes.includes(cliConfig.projectType)) { + findings.push({ + level: "ok", + message: "Detected project files match configured npm-compatible project type", + }); + return; + } + + if (hasDenoJson && !hasPackageJson) { + findings.push({ + level: "warn", + message: "Detected a Deno-only project, but guided config supports npm-compatible project types", + fix: "Use `gitzone config set projectType npm|service|wcc|website` only for npm-compatible projects.", + }); + } +} + /** * Show help for config command */ @@ -728,6 +1662,10 @@ export function showHelp(mode?: ICliMode): void { name: "show", description: "Display current @git.zone/cli configuration", }, + { name: "project", description: "Configure project basics interactively" }, + { name: "cli", description: "Configure CLI behavior interactively" }, + { name: "release", description: "Configure release workflow interactively" }, + { name: "doctor", description: "Validate .smartconfig.json" }, { name: "get ", description: "Read a single config value" }, { name: "set ", description: "Write a config value" }, { name: "unset ", description: "Delete a config value" }, @@ -749,6 +1687,8 @@ export function showHelp(mode?: ICliMode): void { ], examples: [ "gitzone config show --json", + "gitzone config project", + "gitzone config doctor --json", "gitzone config get release.targets.npm.accessLevel", "gitzone config set cli.interactive false", "gitzone config set cli.output json", @@ -764,6 +1704,10 @@ export function showHelp(mode?: ICliMode): void { console.log( " show Display current @git.zone/cli configuration", ); + console.log(" project Configure project basics interactively"); + console.log(" cli Configure CLI behavior interactively"); + console.log(" release Configure release workflow interactively"); + console.log(" doctor Validate .smartconfig.json"); console.log(" get Read a single config value"); console.log(" set Write a config value"); console.log(" unset Delete a config value"); @@ -782,6 +1726,10 @@ export function showHelp(mode?: ICliMode): void { console.log("Examples:"); console.log(" gitzone config show"); console.log(" gitzone config show --json"); + console.log(" gitzone config project"); + console.log(" gitzone config cli"); + console.log(" gitzone config release"); + console.log(" gitzone config doctor --json"); console.log(" gitzone config get release.targets.npm.accessLevel"); console.log(" gitzone config set cli.interactive false"); console.log(" gitzone config set cli.output json");