fix(config): migrate legacy release arrays during config fixes and validate release config shape

This commit is contained in:
2026-05-14 13:25:56 +00:00
parent 278df40ba7
commit b234ecc12a
3 changed files with 105 additions and 10 deletions
+8
View File
@@ -3,6 +3,14 @@
## Pending ## 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 ## 2026-05-13 - 2.19.0
### Features ### Features
+37 -1
View File
@@ -19,6 +19,38 @@ const ensureObject = (parent: Record<string, any>, key: string): Record<string,
return parent[key]; return parent[key];
}; };
const normalizeRegistryList = (registries: unknown[]): string[] => {
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<string, any>): 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<string, any>): boolean => { const migrateNamespaceKeys = (smartconfigJson: Record<string, any>): boolean => {
let migrated = false; let migrated = false;
const migrations = [ const migrations = [
@@ -50,9 +82,9 @@ const migrateNamespaceKeys = (smartconfigJson: Record<string, any>): boolean =>
const migrateToV2 = (smartconfigJson: Record<string, any>): boolean => { const migrateToV2 = (smartconfigJson: Record<string, any>): boolean => {
const cliConfig = ensureObject(smartconfigJson, CLI_NAMESPACE); const cliConfig = ensureObject(smartconfigJson, CLI_NAMESPACE);
let migrated = migrateLegacyReleaseArray(smartconfigJson);
const releaseConfig = ensureObject(cliConfig, "release"); const releaseConfig = ensureObject(cliConfig, "release");
let migrated = false;
const targets = ensureObject(releaseConfig, "targets"); const targets = ensureObject(releaseConfig, "targets");
const shipzoneConfig = smartconfigJson["@ship.zone/szci"]; const shipzoneConfig = smartconfigJson["@ship.zone/szci"];
@@ -192,6 +224,10 @@ export const migrateSmartconfigData = (
const fromVersion = typeof cliConfig.schemaVersion === "number" ? cliConfig.schemaVersion : 1; const fromVersion = typeof cliConfig.schemaVersion === "number" ? cliConfig.schemaVersion : 1;
let currentVersion = fromVersion; let currentVersion = fromVersion;
if (targetVersion >= 2) {
migrated = migrateLegacyReleaseArray(smartconfigJson) || migrated;
}
if (currentVersion < 2 && targetVersion >= 2) { if (currentVersion < 2 && targetVersion >= 2) {
migrated = migrateToV2(smartconfigJson) || migrated; migrated = migrateToV2(smartconfigJson) || migrated;
currentVersion = 2; currentVersion = 2;
+60 -9
View File
@@ -168,7 +168,7 @@ async function handleInteractiveMenu(): Promise<void> {
{ name: "Configure release workflow", value: "release" }, { name: "Configure release workflow", value: "release" },
{ name: "Configure services", value: "services" }, { name: "Configure services", value: "services" },
{ name: "Validate configuration (doctor)", value: "doctor" }, { 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: "Add an npm target registry", value: "add" },
{ name: "Remove an npm target registry", value: "remove" }, { name: "Remove an npm target registry", value: "remove" },
{ name: "Clear npm target registries", value: "clear" }, { name: "Clear npm target registries", value: "clear" },
@@ -939,8 +939,8 @@ async function handleFix(argvArg: any, mode: ICliMode): Promise<void> {
return; return;
} }
const findings = await collectDoctorFindings(); let findings = await collectDoctorFindings();
const counts = countDoctorFindings(findings); let counts = countDoctorFindings(findings);
const extraInstructions = (argvArg._?.slice(2).join(" ") || "").trim(); const extraInstructions = (argvArg._?.slice(2).join(" ") || "").trim();
const force = Boolean(argvArg.force); const force = Boolean(argvArg.force);
@@ -954,10 +954,10 @@ async function handleFix(argvArg: any, mode: ICliMode): Promise<void> {
if (!mode.yes) { if (!mode.yes) {
if (!mode.interactive) { 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( 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, true,
); );
if (!confirmed) { if (!confirmed) {
@@ -966,6 +966,16 @@ async function handleFix(argvArg: any, mode: ICliMode): Promise<void> {
} }
} }
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 = [ const opencodeArgs = [
"run", "run",
"--title", "--title",
@@ -1004,6 +1014,33 @@ async function handleFix(argvArg: any, mode: ICliMode): Promise<void> {
printDoctorResult(finalFindings, mode); printDoctorResult(finalFindings, mode);
} }
async function applyKnownConfigFixes(mode: ICliMode): Promise<boolean> {
const smartconfigPath = getSmartconfigPath();
if (!(await plugins.smartfs.file(smartconfigPath).exists())) {
return false;
}
let smartconfigData: Record<string, any>;
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<IDoctorFinding[]> { async function collectDoctorFindings(): Promise<IDoctorFinding[]> {
const findings: IDoctorFinding[] = []; const findings: IDoctorFinding[] = [];
const smartconfigPath = getSmartconfigPath(); const smartconfigPath = getSmartconfigPath();
@@ -1071,7 +1108,7 @@ async function collectDoctorFindings(): Promise<IDoctorFinding[]> {
await validateDetectedProjectType(cliConfig, findings); await validateDetectedProjectType(cliConfig, findings);
validateCommitConfig(cliConfig.commit || {}, findings); validateCommitConfig(cliConfig.commit || {}, findings);
await validateReleaseConfig(cliConfig.release || {}, smartconfigData, findings); await validateReleaseConfig(cliConfig.release, smartconfigData, findings);
return findings; return findings;
} }
@@ -1570,10 +1607,24 @@ function validateCommitConfig(
} }
async function validateReleaseConfig( async function validateReleaseConfig(
releaseConfig: Record<string, any>, rawReleaseConfig: unknown,
smartconfigData: Record<string, any>, smartconfigData: Record<string, any>,
findings: IDoctorFinding[], findings: IDoctorFinding[],
): Promise<void> { ): Promise<void> {
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; const confirmation = releaseConfig.confirmation;
if (confirmation === undefined || validConfirmationModes.includes(confirmation)) { if (confirmation === undefined || validConfirmationModes.includes(confirmation)) {
findings.push({ level: "ok", message: "Release confirmation mode is valid" }); 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: "cli", description: "Configure CLI behavior interactively" },
{ name: "release", description: "Configure release workflow interactively" }, { name: "release", description: "Configure release workflow interactively" },
{ name: "doctor", description: "Validate .smartconfig.json" }, { 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 <path>", description: "Read a single config value" }, { name: "get <path>", description: "Read a single config value" },
{ name: "set <path> <value>", description: "Write a config value" }, { name: "set <path> <value>", description: "Write a config value" },
{ name: "unset <path>", description: "Delete a config value" }, { name: "unset <path>", description: "Delete a config value" },
@@ -2038,7 +2089,7 @@ export function showHelp(mode?: ICliMode): void {
console.log(" cli Configure CLI behavior interactively"); console.log(" cli Configure CLI behavior interactively");
console.log(" release Configure release workflow interactively"); console.log(" release Configure release workflow interactively");
console.log(" doctor Validate .smartconfig.json"); 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 <path> Read a single config value"); console.log(" get <path> Read a single config value");
console.log(" set <path> <value> Write a config value"); console.log(" set <path> <value> Write a config value");
console.log(" unset <path> Delete a config value"); console.log(" unset <path> Delete a config value");