diff --git a/changelog.md b/changelog.md index 1a5bb27..40d6862 100644 --- a/changelog.md +++ b/changelog.md @@ -3,6 +3,14 @@ ## Pending +### Fixes + +- migrate legacy release arrays during config fixes and validate release config shape (config) + - Automatically converts legacy release registry arrays into release.targets.npm.registries during smartconfig migration and config fix runs. + - Re-runs doctor checks after applying known migrations so resolved issues do not require the external fixer. + - Reports an explicit validation error when release config is not an object. + - Updates config fix prompts and help text to use generic configuration repair wording. + ## 2026-05-13 - 2.19.0 ### Features diff --git a/ts/helpers.smartconfigmigrations.ts b/ts/helpers.smartconfigmigrations.ts index 3688388..0b221c6 100644 --- a/ts/helpers.smartconfigmigrations.ts +++ b/ts/helpers.smartconfigmigrations.ts @@ -19,6 +19,38 @@ const ensureObject = (parent: Record, key: string): Record { + const result: string[] = []; + for (const registry of registries) { + if (typeof registry !== "string" || !registry.trim()) { + continue; + } + const normalizedRegistry = normalizeRegistryUrl(registry); + if (!result.includes(normalizedRegistry)) { + result.push(normalizedRegistry); + } + } + return result; +}; + +const migrateLegacyReleaseArray = (smartconfigJson: Record): boolean => { + const cliConfig = ensureObject(smartconfigJson, CLI_NAMESPACE); + if (!Array.isArray(cliConfig.release)) { + return false; + } + + const registries = normalizeRegistryList(cliConfig.release); + cliConfig.release = { + targets: { + npm: { + enabled: registries.length > 0, + registries, + }, + }, + }; + return true; +}; + const migrateNamespaceKeys = (smartconfigJson: Record): boolean => { let migrated = false; const migrations = [ @@ -50,9 +82,9 @@ const migrateNamespaceKeys = (smartconfigJson: Record): boolean => const migrateToV2 = (smartconfigJson: Record): boolean => { const cliConfig = ensureObject(smartconfigJson, CLI_NAMESPACE); + let migrated = migrateLegacyReleaseArray(smartconfigJson); const releaseConfig = ensureObject(cliConfig, "release"); - let migrated = false; const targets = ensureObject(releaseConfig, "targets"); const shipzoneConfig = smartconfigJson["@ship.zone/szci"]; @@ -192,6 +224,10 @@ export const migrateSmartconfigData = ( const fromVersion = typeof cliConfig.schemaVersion === "number" ? cliConfig.schemaVersion : 1; let currentVersion = fromVersion; + if (targetVersion >= 2) { + migrated = migrateLegacyReleaseArray(smartconfigJson) || migrated; + } + if (currentVersion < 2 && targetVersion >= 2) { migrated = migrateToV2(smartconfigJson) || migrated; currentVersion = 2; diff --git a/ts/mod_config/index.ts b/ts/mod_config/index.ts index 2a59367..b7f4e62 100644 --- a/ts/mod_config/index.ts +++ b/ts/mod_config/index.ts @@ -168,7 +168,7 @@ async function handleInteractiveMenu(): Promise { { name: "Configure release workflow", value: "release" }, { name: "Configure services", value: "services" }, { name: "Validate configuration (doctor)", value: "doctor" }, - { name: "Fix configuration with opencode", value: "fix" }, + { 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" }, @@ -939,8 +939,8 @@ async function handleFix(argvArg: any, mode: ICliMode): Promise { return; } - const findings = await collectDoctorFindings(); - const counts = countDoctorFindings(findings); + let findings = await collectDoctorFindings(); + let counts = countDoctorFindings(findings); const extraInstructions = (argvArg._?.slice(2).join(" ") || "").trim(); const force = Boolean(argvArg.force); @@ -954,10 +954,10 @@ async function handleFix(argvArg: any, mode: ICliMode): Promise { if (!mode.yes) { if (!mode.interactive) { - throw new Error("Config fix requires an interactive terminal or `-y` to run opencode non-interactively."); + throw new Error("Config fix requires an interactive terminal or `-y` to run non-interactively."); } const confirmed = await plugins.smartinteract.SmartInteract.getCliConfirmation( - `Run opencode to fix .smartconfig.json? (${counts.error} error, ${counts.warn} warning)`, + `Run configuration fixes for .smartconfig.json? (${counts.error} error, ${counts.warn} warning)`, true, ); if (!confirmed) { @@ -966,6 +966,16 @@ async function handleFix(argvArg: any, mode: ICliMode): Promise { } } + 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", @@ -1004,6 +1014,33 @@ async function handleFix(argvArg: any, mode: ICliMode): Promise { 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(); @@ -1071,7 +1108,7 @@ async function collectDoctorFindings(): Promise { await validateDetectedProjectType(cliConfig, findings); validateCommitConfig(cliConfig.commit || {}, findings); - await validateReleaseConfig(cliConfig.release || {}, smartconfigData, findings); + await validateReleaseConfig(cliConfig.release, smartconfigData, findings); return findings; } @@ -1570,10 +1607,24 @@ function validateCommitConfig( } async function validateReleaseConfig( - releaseConfig: Record, + 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" }); @@ -1993,7 +2044,7 @@ export function showHelp(mode?: ICliMode): void { { name: "cli", description: "Configure CLI behavior interactively" }, { name: "release", description: "Configure release workflow interactively" }, { name: "doctor", description: "Validate .smartconfig.json" }, - { name: "fix [instructions]", description: "Use opencode to repair .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" }, @@ -2038,7 +2089,7 @@ export function showHelp(mode?: ICliMode): void { console.log(" cli Configure CLI behavior interactively"); console.log(" release Configure release workflow interactively"); console.log(" doctor Validate .smartconfig.json"); - console.log(" fix [instructions] Use opencode to repair .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");