// gitzone config - manage CLI smartconfig configuration import * as plugins from "./mod.plugins.js"; import { ReleaseConfig } from "./classes.releaseconfig.js"; import { CommitConfig } from "./classes.commitconfig.js"; 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, writeSmartconfigFile, } from "../helpers.smartconfig.js"; import { CURRENT_GITZONE_CLI_SCHEMA_VERSION, migrateSmartconfigData, } from "../helpers.smartconfigmigrations.js"; export { ReleaseConfig, CommitConfig }; const defaultCliMode: ICliMode = { output: "human", interactive: true, json: false, plain: false, quiet: false, yes: false, help: false, agent: false, checkUpdates: true, isTty: true, }; /** * Format .smartconfig.json with diff preview * Shows diff first, asks for confirmation, then applies */ async function formatSmartconfigWithDiff(mode: ICliMode): Promise { if (!mode.interactive) { return; } // Check for diffs first const checkResult = (await runFormatter("smartconfig", { checkOnly: true, showDiff: true, })) as ICheckResult | void; if (checkResult && checkResult.hasDiff) { const shouldApply = await plugins.smartinteract.SmartInteract.getCliConfirmation( "Apply formatting changes to .smartconfig.json?", true, ); if (shouldApply) { await runFormatter("smartconfig", { silent: true }); } } } export const run = async (argvArg: any) => { const mode = await getCliMode(argvArg); const command = argvArg._?.[1]; const value = argvArg._?.[2]; if (mode.help || command === "help") { showHelp(mode); return; } // If no command provided, show interactive menu if (!command) { if (!mode.interactive) { showHelp(mode); return; } await handleInteractiveMenu(); return; } switch (command) { case "show": await handleShow(mode); break; case "add": await handleAdd(value, mode); break; case "remove": await handleRemove(value, mode); break; case "clear": await handleClear(mode); break; case "access": case "accessLevel": await handleAccessLevel(value, mode); break; 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 "fix": await handleFix(argvArg, mode); break; case "migrate": await handleMigrate(value, mode); break; case "get": await handleGet(value, mode); break; case "set": await handleSet(value, argvArg._?.[3], mode); break; case "unset": await handleUnset(value, mode); break; default: plugins.logger.log("error", `Unknown command: ${command}`); showHelp(mode); } }; /** * Interactive menu for config command */ async function handleInteractiveMenu(): Promise { console.log(""); console.log( "╭─────────────────────────────────────────────────────────────╮", ); console.log( "│ gitzone config - Project Configuration │", ); console.log( "╰─────────────────────────────────────────────────────────────╯", ); console.log(""); const interactInstance = new plugins.smartinteract.SmartInteract(); const response = await interactInstance.askQuestion({ type: "list", name: "action", message: "What would you like to do?", 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: "Fix configuration", value: "fix" }, { 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: "Show help", value: "help" }, ], }); const action = (response as any).value; switch (action) { 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; case "remove": await handleRemove(undefined, defaultCliMode); break; case "clear": await handleClear(defaultCliMode); break; case "access": await handleAccessLevel(undefined, defaultCliMode); break; case "migrate": await handleMigrate(undefined, defaultCliMode); break; case "commit": await handleCommit(undefined, undefined, defaultCliMode); break; case "services": await handleServices(defaultCliMode); break; case "doctor": await handleDoctor(defaultCliMode); break; case "fix": await handleFix({ _: ["config", "fix"] }, defaultCliMode); break; case "help": showHelp(); break; } } /** * Show current CLI project configuration */ async function handleShow(mode: ICliMode): Promise { const smartconfigData = await readSmartconfigFile(); const cliConfig = getCliConfigValueFromData(smartconfigData, "") || {}; if (mode.json) { printJson(cliConfig); return; } console.log(""); console.log( "╭─────────────────────────────────────────────────────────────╮", ); console.log( "│ gitzone config - Project Configuration │", ); console.log( "╰─────────────────────────────────────────────────────────────╯", ); console.log(""); 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(""); } /** * Add an npm target registry URL */ async function handleAdd( url: string | undefined, mode: ICliMode, ): Promise { if (!url) { if (!mode.interactive) { throw new Error("Registry URL is required in non-interactive mode"); } // Interactive mode const interactInstance = new plugins.smartinteract.SmartInteract(); const response = await interactInstance.askQuestion({ type: "input", name: "registryUrl", message: "Enter npm target registry URL:", default: "https://registry.npmjs.org", validate: (input: string) => { return !!(input && input.trim() !== ""); }, }); url = (response as any).value; } const config = await ReleaseConfig.fromCwd(); const added = config.addRegistry(url!); if (added) { await config.save(); if (mode.json) { printJson({ ok: true, action: "add", registry: url, registries: config.getRegistries(), }); return; } plugins.logger.log("success", `Added npm target registry: ${url}`); await formatSmartconfigWithDiff(mode); } else { plugins.logger.log("warn", `Registry already exists: ${url}`); } } /** * Remove an npm target registry URL */ async function handleRemove( url: string | undefined, mode: ICliMode, ): Promise { const config = await ReleaseConfig.fromCwd(); const registries = config.getRegistries(); if (registries.length === 0) { plugins.logger.log("warn", "No npm target registries configured to remove."); return; } if (!url) { if (!mode.interactive) { throw new Error("Registry URL is required in non-interactive mode"); } // Interactive mode - show list to select from const interactInstance = new plugins.smartinteract.SmartInteract(); const response = await interactInstance.askQuestion({ type: "list", name: "registryUrl", message: "Select npm target registry to remove:", choices: registries, default: registries[0], }); url = (response as any).value; } const removed = config.removeRegistry(url!); if (removed) { await config.save(); if (mode.json) { printJson({ ok: true, action: "remove", registry: url, registries: config.getRegistries(), }); return; } plugins.logger.log("success", `Removed npm target registry: ${url}`); await formatSmartconfigWithDiff(mode); } else { plugins.logger.log("warn", `Registry not found: ${url}`); } } /** * Clear all npm target registries */ async function handleClear(mode: ICliMode): Promise { const config = await ReleaseConfig.fromCwd(); if (!config.hasRegistries()) { plugins.logger.log("info", "No npm target registries to clear."); return; } // Confirm before clearing const confirmed = mode.interactive ? await plugins.smartinteract.SmartInteract.getCliConfirmation( "Clear all configured npm target registries?", false, ) : true; if (confirmed) { config.clearRegistries(); await config.save(); if (mode.json) { printJson({ ok: true, action: "clear", registries: [] }); return; } plugins.logger.log("success", "All npm target registries cleared."); await formatSmartconfigWithDiff(mode); } else { plugins.logger.log("info", "Operation cancelled."); } } /** * Set or toggle access level */ async function handleAccessLevel( level: string | undefined, mode: ICliMode, ): Promise { const config = await ReleaseConfig.fromCwd(); const currentLevel = config.getAccessLevel(); if (!level) { if (!mode.interactive) { throw new Error("Access level is required in non-interactive mode"); } // Interactive mode - toggle or ask const interactInstance = new plugins.smartinteract.SmartInteract(); const response = await interactInstance.askQuestion({ type: "list", name: "accessLevel", message: "Select npm access level for publishing:", choices: ["public", "private"], default: currentLevel, }); level = (response as any).value; } // Validate the level if (level !== "public" && level !== "private") { plugins.logger.log( "error", `Invalid access level: ${level}. Must be 'public' or 'private'.`, ); return; } if (level === currentLevel) { plugins.logger.log("info", `Access level is already set to: ${level}`); return; } config.setAccessLevel(level as "public" | "private"); await config.save(); if (mode.json) { printJson({ ok: true, action: "access", accessLevel: level }); return; } plugins.logger.log("success", `Access level set to: ${level}`); await formatSmartconfigWithDiff(mode); } /** * Handle commit configuration */ async function handleCommit( setting: string | undefined, value: string | undefined, mode: ICliMode, ): Promise { const config = await CommitConfig.fromCwd(); // No setting = interactive mode if (!setting) { if (!mode.interactive) { throw new Error("Commit setting is required in non-interactive mode"); } await handleCommitInteractive(config); return; } // Direct setting switch (setting) { case "alwaysTest": await handleCommitSetting(config, "alwaysTest", value, mode); break; case "alwaysBuild": await handleCommitSetting(config, "alwaysBuild", value, mode); break; default: plugins.logger.log("error", `Unknown commit setting: ${setting}`); showCommitHelp(); } } /** * Interactive commit configuration */ async function handleCommitInteractive(config: CommitConfig): Promise { console.log(""); console.log( "╭─────────────────────────────────────────────────────────────╮", ); console.log( "│ Commit Configuration │", ); console.log( "╰─────────────────────────────────────────────────────────────╯", ); console.log(""); const interactInstance = new plugins.smartinteract.SmartInteract(); const response = await interactInstance.askQuestion({ type: "checkbox", name: "commitOptions", message: "Select commit options to enable:", choices: [ { name: "Always run tests before commit (-t)", value: "alwaysTest" }, { name: "Always build after commit (-b)", value: "alwaysBuild" }, ], default: [ ...(config.getAlwaysTest() ? ["alwaysTest"] : []), ...(config.getAlwaysBuild() ? ["alwaysBuild"] : []), ], }); const selected = (response as any).value || []; config.setAlwaysTest(selected.includes("alwaysTest")); config.setAlwaysBuild(selected.includes("alwaysBuild")); syncCommitStepsFromBooleans(config); await config.save(); plugins.logger.log("success", "Commit configuration updated"); await formatSmartconfigWithDiff(defaultCliMode); } /** * Set a specific commit setting */ async function handleCommitSetting( config: CommitConfig, setting: string, value: string | undefined, mode: ICliMode, ): Promise { // Parse boolean value const boolValue = value === "true" || value === "1" || value === "on"; if (setting === "alwaysTest") { config.setAlwaysTest(boolValue); } else if (setting === "alwaysBuild") { config.setAlwaysBuild(boolValue); } syncCommitStepsFromBooleans(config); await config.save(); if (mode.json) { printJson({ ok: true, action: "commit", setting, value: boolValue }); return; } plugins.logger.log("success", `Set ${setting} to ${boolValue}`); await formatSmartconfigWithDiff(mode); } function syncCommitStepsFromBooleans(config: CommitConfig): void { config.setSteps([ "analyze", ...(config.getAlwaysTest() ? ["test"] : []), ...(config.getAlwaysBuild() ? ["build"] : []), "changelog", "commit", ]); } /** * Show help for commit subcommand */ function showCommitHelp(): void { console.log(""); console.log("Usage: gitzone config commit [setting] [value]"); console.log(""); console.log("Settings:"); console.log(" alwaysTest [true|false] Always run tests before commit"); console.log(" alwaysBuild [true|false] Always build after commit"); console.log(""); console.log("Examples:"); console.log(" gitzone config commit # Interactive mode"); console.log(" gitzone config commit alwaysTest true"); console.log(" gitzone config commit alwaysBuild false"); 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 through tsdocker", 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 patterns = await askValue(interactInstance, { type: "input", name: "dockerPatterns", message: "tsdocker Dockerfile patterns (comma-separated, empty means all):", default: Array.isArray(currentTargets.docker?.patterns) ? currentTargets.docker.patterns.join(", ") : "", }); const cached = await askValue(interactInstance, { type: "confirm", name: "dockerCached", message: "Use tsdocker cached builds?", default: currentTargets.docker?.cached ?? false, }); const parallel = await askValue(interactInstance, { type: "input", name: "dockerParallel", message: "tsdocker parallel mode (false, true, or concurrency number):", default: formatDockerParallel(currentTargets.docker?.parallel ?? false), }); const context = await askValue(interactInstance, { type: "input", name: "dockerContext", message: "Docker context for tsdocker (empty for default):", default: currentTargets.docker?.context || "", }); const noBuild = await askValue(interactInstance, { type: "confirm", name: "dockerNoBuild", message: "Skip tsdocker build and only push existing local registry images?", default: currentTargets.docker?.noBuild ?? false, }); releaseTargets.docker = { enabled: true, engine: "tsdocker", patterns: parseCsv(patterns), cached, parallel: parseDockerParallel(parallel), context: context.trim() || undefined, noBuild, }; } else { releaseTargets.docker = { enabled: false, engine: "tsdocker" }; } 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 = await collectDoctorFindings(); printDoctorResult(findings, mode); } async function handleFix(argvArg: any, mode: ICliMode): Promise { if (mode.json) { printJson({ ok: false, error: "JSON output is not supported for `gitzone config fix`. Use `gitzone config doctor --json` for machine-readable diagnostics.", }); process.exitCode = 1; return; } let findings = await collectDoctorFindings(); let counts = countDoctorFindings(findings); const extraInstructions = (argvArg._?.slice(2).join(" ") || "").trim(); const force = Boolean(argvArg.force); if (counts.error === 0 && counts.warn === 0 && !extraInstructions && !force) { plugins.logger.log( "success", "Configuration doctor found no issues. Use `gitzone config fix --force` to run opencode anyway.", ); return; } if (!mode.yes) { if (!mode.interactive) { throw new Error("Config fix requires an interactive terminal or `-y` to run non-interactively."); } const confirmed = await plugins.smartinteract.SmartInteract.getCliConfirmation( `Run configuration fixes for .smartconfig.json? (${counts.error} error, ${counts.warn} warning)`, true, ); if (!confirmed) { plugins.logger.log("info", "Config fix cancelled."); return; } } const appliedKnownFixes = await applyKnownConfigFixes(mode); if (appliedKnownFixes) { findings = await collectDoctorFindings(); counts = countDoctorFindings(findings); if (counts.error === 0 && counts.warn === 0 && !extraInstructions && !force) { printDoctorResult(findings, mode); return; } } const opencodeArgs = [ "run", "--title", "gitzone config fix", "--dir", process.cwd(), ]; if (mode.yes) { opencodeArgs.push("--dangerously-skip-permissions"); } opencodeArgs.push(buildConfigFixPrompt(findings, extraInstructions)); plugins.logger.log("info", "Starting opencode configuration fix..."); const smartshellInstance = new plugins.smartshell.Smartshell({ executor: "bash", sourceFilePaths: [], }); let result: plugins.smartshell.IExecResult; try { result = await smartshellInstance.execSpawn("opencode", opencodeArgs, { stdio: "inherit", }); } catch (error) { throw new Error(`Failed to run opencode: ${error instanceof Error ? error.message : String(error)}`); } if (result.exitCode !== 0) { plugins.logger.log("error", `opencode exited with code ${result.exitCode}`); process.exitCode = result.exitCode || 1; return; } await formatSmartconfigWithDiff(mode); const finalFindings = await collectDoctorFindings(); printDoctorResult(finalFindings, mode); } async function applyKnownConfigFixes(mode: ICliMode): Promise { const smartconfigPath = getSmartconfigPath(); if (!(await plugins.smartfs.file(smartconfigPath).exists())) { return false; } let smartconfigData: Record; try { smartconfigData = await readSmartconfigFile(); } catch { return false; } const result = migrateSmartconfigData(smartconfigData); if (!result.migrated) { return false; } await writeSmartconfigFile(smartconfigData); plugins.logger.log( "success", `Applied known .smartconfig.json migrations to schema v${result.toVersion}`, ); await formatSmartconfigWithDiff(mode); return true; } async function collectDoctorFindings(): 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 findings; } 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 findings; } 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, smartconfigData, findings); return findings; } /** * Handle services configuration */ async function handleServices(mode: ICliMode): Promise { if (!mode.interactive) { throw new Error( "Use `gitzone services config --json` or `gitzone services set ...` in non-interactive mode", ); } // Import and use ServiceManager's configureServices const { ServiceManager } = await import("../mod_services/classes.servicemanager.js"); const serviceManager = new ServiceManager(); await serviceManager.init(); await serviceManager.configureServices(); } async function handleGet( configPath: string | undefined, mode: ICliMode, ): Promise { if (!configPath) { throw new Error("Configuration path is required"); } const smartconfigData = await readSmartconfigFile(); const value = getCliConfigValueFromData(smartconfigData, configPath); if (mode.json) { printJson({ path: configPath, value, exists: value !== undefined }); return; } if (value === undefined) { plugins.logger.log("warn", `No value set for ${configPath}`); return; } if (typeof value === "string") { console.log(value); return; } printJson(value); } async function handleSet( configPath: string | undefined, rawValue: string | undefined, mode: ICliMode, ): Promise { if (!configPath) { throw new Error("Configuration path is required"); } if (rawValue === undefined) { throw new Error("Configuration value is required"); } const smartconfigData = await readSmartconfigFile(); const parsedValue = parseConfigValue(rawValue); setCliConfigValueInData(smartconfigData, configPath, parsedValue); await writeSmartconfigFile(smartconfigData); if (mode.json) { printJson({ ok: true, action: "set", path: configPath, value: parsedValue, }); return; } plugins.logger.log("success", `Set ${configPath}`); } async function handleUnset( configPath: string | undefined, mode: ICliMode, ): Promise { if (!configPath) { throw new Error("Configuration path is required"); } const smartconfigData = await readSmartconfigFile(); const removed = unsetCliConfigValueInData(smartconfigData, configPath); if (!removed) { if (mode.json) { printJson({ ok: false, action: "unset", path: configPath, removed: false, }); return; } plugins.logger.log("warn", `No value set for ${configPath}`); return; } await writeSmartconfigFile(smartconfigData); if (mode.json) { printJson({ ok: true, action: "unset", path: configPath, removed: true }); return; } plugins.logger.log("success", `Unset ${configPath}`); } async function handleMigrate( rawTargetVersion: string | undefined, mode: ICliMode, ): Promise { const targetVersion = rawTargetVersion ? Number(rawTargetVersion) : CURRENT_GITZONE_CLI_SCHEMA_VERSION; if (!Number.isInteger(targetVersion) || targetVersion < 1) { throw new Error("Migration target version must be a positive integer"); } const smartconfigData = await readSmartconfigFile(); const result = migrateSmartconfigData(smartconfigData, targetVersion); if (result.migrated) { await writeSmartconfigFile(smartconfigData); } if (mode.json) { printJson({ ok: true, action: "migrate", ...result }); return; } if (result.migrated) { plugins.logger.log( "success", `Migrated .smartconfig.json from schema v${result.fromVersion} to v${result.toVersion}`, ); } else { plugins.logger.log("info", `.smartconfig.json already at schema v${result.toVersion}`); } } function parseConfigValue(rawValue: string): any { const trimmedValue = rawValue.trim(); if (trimmedValue === "true") { return true; } if (trimmedValue === "false") { return false; } if (trimmedValue === "null") { return null; } if (/^-?\d+(\.\d+)?$/.test(trimmedValue)) { return Number(trimmedValue); } if ( (trimmedValue.startsWith("{") && trimmedValue.endsWith("}")) || (trimmedValue.startsWith("[") && trimmedValue.endsWith("]")) || (trimmedValue.startsWith('"') && trimmedValue.endsWith('"')) ) { return JSON.parse(trimmedValue); } 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 (targetConfig.engine) details.push(`engine=${targetConfig.engine}`); if (Array.isArray(targetConfig.patterns)) { details.push(`patterns=${targetConfig.patterns.length}`); } if (targetConfig.cached) details.push("cached=true"); if (targetConfig.parallel) details.push(`parallel=${targetConfig.parallel}`); if (targetConfig.context) details.push(`context=${targetConfig.context}`); if (targetConfig.noBuild) details.push("noBuild=true"); 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 formatDockerParallel(value: unknown): string { if (value === true) return "true"; if (typeof value === "number" && Number.isFinite(value) && value > 0) { return String(Math.floor(value)); } return "false"; } function parseDockerParallel(value: string): boolean | number { const normalizedValue = value.trim().toLowerCase(); if (!normalizedValue || ["false", "no", "off", "0"].includes(normalizedValue)) { return false; } if (["true", "yes", "on"].includes(normalizedValue)) { return true; } const numericValue = Number(normalizedValue); if (Number.isFinite(numericValue) && numericValue > 0) { return Math.floor(numericValue); } return false; } 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 countDoctorFindings( findings: IDoctorFinding[], ): Record { return findings.reduce( (accumulator, finding) => { accumulator[finding.level] += 1; return accumulator; }, { ok: 0, warn: 0, error: 0 } as Record, ); } function buildConfigFixPrompt( findings: IDoctorFinding[], extraInstructions: string, ): string { const promptParts = [ "Other /c-* commands can be found at ~/.config/opencode/commands/*", "# gitzone config fix", "", `Working directory: ${process.cwd()}`, "", "Repair the project configuration so `gitzone config doctor --json` passes.", "", "Rules:", "- Read `.smartconfig.json`, `package.json`, and nearby project metadata before editing.", "- Keep gitzone CLI config under `@git.zone/cli` in `.smartconfig.json`.", `- Use schemaVersion ${CURRENT_GITZONE_CLI_SCHEMA_VERSION} for ` + "`@git.zone/cli`.", "- Use target-based release config: `release.targets.git`, `release.targets.npm`, and `release.targets.docker`.", "- Docker release targets must use `release.targets.docker.engine = \"tsdocker\"`; Docker registries belong under `@git.zone/tsdocker`.", "- Keep npm registries only at `@git.zone/cli.release.targets.npm.registries`.", "- Do not add runtime legacy compatibility code. If legacy config exists, migrate it explicitly.", "- Do not commit, release, install dependencies, or modify unrelated files.", "- Use pnpm commands only if commands are needed.", "- Run `gitzone config doctor --json` after changes and keep fixing until no errors remain.", "- Run `git diff --check` after changes to catch whitespace problems.", "", "Current doctor findings:", JSON.stringify(findings, null, 2), ]; if (extraInstructions) { promptParts.push("", "Additional user instructions:", extraInstructions); } return promptParts.join("\n"); } function printDoctorResult(findings: IDoctorFinding[], mode: ICliMode): void { const counts = countDoctorFindings(findings); 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( rawReleaseConfig: unknown, smartconfigData: Record, findings: IDoctorFinding[], ): Promise { const releaseConfig = rawReleaseConfig === undefined ? {} : rawReleaseConfig; if (!isPlainObject(releaseConfig)) { findings.push({ level: "error", message: `Release config must be an object, found ${ Array.isArray(releaseConfig) ? "array" : typeof releaseConfig }`, fix: Array.isArray(releaseConfig) ? "Run `gitzone config migrate` to move legacy registry arrays into release.targets.npm.registries." : "Set @git.zone/cli.release to an object or remove it.", }); return; } 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); await validateDockerTarget(targets.docker || {}, smartconfigData, 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), }); } } async function validateDockerTarget( dockerTarget: Record, smartconfigData: Record, findings: IDoctorFinding[], ): Promise { if ("images" in dockerTarget) { findings.push({ level: "error", message: "Docker release target still uses removed images config", fix: "Remove release.targets.docker.images and configure @git.zone/tsdocker instead.", }); } const enabled = dockerTarget.enabled ?? false; if (!enabled) { findings.push({ level: "ok", message: "Docker release target is disabled" }); return; } if (dockerTarget.engine !== "tsdocker") { findings.push({ level: "error", message: "Docker release target must use tsdocker", fix: "Set release.targets.docker.engine to tsdocker.", }); } if (dockerTarget.patterns !== undefined && !Array.isArray(dockerTarget.patterns)) { findings.push({ level: "error", message: "Docker release target patterns must be an array", fix: "Set release.targets.docker.patterns to an array of Dockerfile patterns or remove it.", }); } if (!isValidDockerParallel(dockerTarget.parallel)) { findings.push({ level: "error", message: `Invalid tsdocker parallel setting: ${formatValue(dockerTarget.parallel)}`, fix: "Use false, true, or a positive concurrency number.", }); } const tsdockerConfig = smartconfigData["@git.zone/tsdocker"]; if (!isPlainObject(tsdockerConfig)) { findings.push({ level: "error", message: "Docker release target is enabled but @git.zone/tsdocker config is missing", fix: "Add @git.zone/tsdocker.registries and optional registryRepoMap/platforms config.", }); } else { validateTsdockerProjectConfig(tsdockerConfig, findings); } await validateTsdockerCommand(findings); findings.push({ level: "ok", message: `Docker release target uses tsdocker (${formatDockerPatterns(dockerTarget.patterns)})`, }); } function formatDockerPatterns(patterns: unknown): string { return Array.isArray(patterns) && patterns.length > 0 ? patterns.map((pattern) => String(pattern)).join(", ") : "all Dockerfiles"; } function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function isValidDockerParallel(value: unknown): boolean { return value === undefined || value === false || value === true || (typeof value === "number" && Number.isFinite(value) && value > 0); } function validateTsdockerProjectConfig( tsdockerConfig: Record, findings: IDoctorFinding[], ): void { const registries = Array.isArray(tsdockerConfig.registries) ? tsdockerConfig.registries : []; if (registries.length === 0) { findings.push({ level: "error", message: "@git.zone/tsdocker.registries is empty", fix: "Set @git.zone/tsdocker.registries to registry hosts such as registry.gitlab.com.", }); } for (const registry of registries) { if (typeof registry !== "string" || !registry.trim()) { findings.push({ level: "error", message: `Invalid tsdocker registry: ${formatValue(registry)}`, fix: "Use registry hosts such as registry.gitlab.com.", }); continue; } if (registry.startsWith("http://") || registry.startsWith("https://")) { findings.push({ level: "error", message: `tsdocker registry must not include a protocol: ${registry}`, fix: `Use ${registry.replace(/^https?:\/\//, "")}`, }); } } const registryRepoMap = tsdockerConfig.registryRepoMap; if (registryRepoMap !== undefined && !isPlainObject(registryRepoMap)) { findings.push({ level: "error", message: "@git.zone/tsdocker.registryRepoMap must be an object", }); } else if (isPlainObject(registryRepoMap)) { for (const registry of Object.keys(registryRepoMap)) { if (registry.startsWith("http://") || registry.startsWith("https://")) { findings.push({ level: "error", message: `tsdocker registryRepoMap key must not include a protocol: ${registry}`, fix: `Use ${registry.replace(/^https?:\/\//, "")}`, }); } } } findings.push({ level: "ok", message: `@git.zone/tsdocker has ${registries.length} registries`, }); } async function validateTsdockerCommand(findings: IDoctorFinding[]): Promise { const smartshellInstance = new plugins.smartshell.Smartshell({ executor: "bash", sourceFilePaths: [], }); try { const result = await smartshellInstance.execSpawn( "tsdocker", ["--version"], { silent: true, timeout: 8000 }, ); if (result.exitCode === 0) { findings.push({ level: "ok", message: "tsdocker command is available" }); } else { findings.push({ level: "error", message: "tsdocker command is not available", fix: "Install @git.zone/tsdocker globally or make it available on PATH.", }); } } catch (error) { findings.push({ level: "error", message: "Could not execute tsdocker", fix: error instanceof Error ? error.message : String(error), }); } } 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 */ export function showHelp(mode?: ICliMode): void { if (mode?.json) { printJson({ command: "config", usage: "gitzone config [options]", commands: [ { 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: "fix [instructions]", description: "Repair .smartconfig.json" }, { name: "get ", description: "Read a single config value" }, { name: "set ", description: "Write a config value" }, { name: "unset ", description: "Delete a config value" }, { name: "add [url]", description: "Add an npm release target registry" }, { name: "remove [url]", description: "Remove an npm release target registry" }, { name: "clear", description: "Clear npm release target registries" }, { name: "access [public|private]", description: "Set npm target publish access level", }, { name: "commit ", description: "Set commit defaults", }, { name: "migrate [version]", description: "Run version-targeted .smartconfig.json migrations", }, ], examples: [ "gitzone config show --json", "gitzone config project", "gitzone config doctor --json", "gitzone config fix", "gitzone config fix -y", "gitzone config get release.targets.npm.accessLevel", "gitzone config set cli.interactive false", "gitzone config set cli.output json", ], }); return; } console.log(""); console.log("Usage: gitzone config [options]"); console.log(""); console.log("Commands:"); 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(" fix [instructions] Repair .smartconfig.json"); console.log(" get Read a single config value"); console.log(" set Write a config value"); console.log(" unset Delete a config value"); console.log(" add [url] Add an npm target registry URL"); console.log(" remove [url] Remove an npm target registry URL"); console.log(" clear Clear npm target registries"); console.log( " access [public|private] Set npm target access level for publishing", ); console.log(" commit [setting] [value] Configure commit options"); console.log(" migrate [version] Run version-targeted smartconfig migrations"); console.log( " services Configure which services are enabled", ); console.log(""); 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 fix"); console.log(" gitzone config fix -y"); 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"); console.log(" gitzone config unset cli.output"); console.log(" gitzone config add https://registry.npmjs.org"); console.log(" gitzone config add https://verdaccio.example.com"); console.log(" gitzone config remove https://registry.npmjs.org"); console.log(" gitzone config clear"); console.log(" gitzone config access public"); console.log(" gitzone config access private"); console.log(" gitzone config migrate 2"); console.log(" gitzone config commit # Interactive"); console.log(" gitzone config commit alwaysTest true"); console.log(" gitzone config services # Interactive"); console.log(""); }