1884 lines
58 KiB
TypeScript
1884 lines
58 KiB
TypeScript
// 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<void> {
|
|
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<void> {
|
|
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 with opencode", 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
// 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<void> {
|
|
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<string>(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<string>(interactInstance, {
|
|
type: "input",
|
|
name: "githost",
|
|
message: "Git host:",
|
|
default: moduleConfig.githost || "code.foss.global",
|
|
});
|
|
const gitscope = await askValue<string>(interactInstance, {
|
|
type: "input",
|
|
name: "gitscope",
|
|
message: "Git scope/owner:",
|
|
default: moduleConfig.gitscope || "git.zone",
|
|
});
|
|
const gitrepo = await askValue<string>(interactInstance, {
|
|
type: "input",
|
|
name: "gitrepo",
|
|
message: "Git repository name:",
|
|
default: moduleConfig.gitrepo || inferRepoName(packageJson.name),
|
|
});
|
|
const description = await askValue<string>(interactInstance, {
|
|
type: "input",
|
|
name: "description",
|
|
message: "Project description:",
|
|
default: moduleConfig.description || packageJson.description || "",
|
|
});
|
|
const npmPackagename = await askValue<string>(interactInstance, {
|
|
type: "input",
|
|
name: "npmPackagename",
|
|
message: "npm package name:",
|
|
default: moduleConfig.npmPackagename || moduleConfig.npmPackageName || packageJson.name || "",
|
|
});
|
|
const license = await askValue<string>(interactInstance, {
|
|
type: "input",
|
|
name: "license",
|
|
message: "License:",
|
|
default: moduleConfig.license || packageJson.license || "MIT",
|
|
});
|
|
const keywords = await askValue<string>(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<void> {
|
|
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<string>(interactInstance, {
|
|
type: "list",
|
|
name: "output",
|
|
message: "Default output mode:",
|
|
choices: ["human", "plain", "json"],
|
|
default: cliConfig.output || "human",
|
|
});
|
|
const interactive = await askValue<boolean>(interactInstance, {
|
|
type: "confirm",
|
|
name: "interactive",
|
|
message: "Enable interactive prompts by default?",
|
|
default: cliConfig.interactive ?? true,
|
|
});
|
|
const checkUpdates = await askValue<boolean>(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<void> {
|
|
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<string>(interactInstance, {
|
|
type: "list",
|
|
name: "confirmation",
|
|
message: "Release confirmation mode:",
|
|
choices: ["prompt", "auto", "plan"],
|
|
default: currentRelease.confirmation || "prompt",
|
|
});
|
|
const requireCleanTree = await askValue<boolean>(interactInstance, {
|
|
type: "confirm",
|
|
name: "requireCleanTree",
|
|
message: "Require a clean git tree before release?",
|
|
default: currentRelease.preflight?.requireCleanTree ?? true,
|
|
});
|
|
const runTests = await askValue<boolean>(interactInstance, {
|
|
type: "confirm",
|
|
name: "runTests",
|
|
message: "Run tests during release preflight?",
|
|
default: currentRelease.preflight?.test ?? false,
|
|
});
|
|
const runBuild = await askValue<boolean>(interactInstance, {
|
|
type: "confirm",
|
|
name: "runBuild",
|
|
message: "Run build during release?",
|
|
default: currentRelease.preflight?.build ?? true,
|
|
});
|
|
const testCommand = await askValue<string>(interactInstance, {
|
|
type: "input",
|
|
name: "testCommand",
|
|
message: "Release test command:",
|
|
default: currentRelease.preflight?.testCommand || "pnpm test",
|
|
});
|
|
const buildCommand = await askValue<string>(interactInstance, {
|
|
type: "input",
|
|
name: "buildCommand",
|
|
message: "Release build command:",
|
|
default: currentRelease.preflight?.buildCommand || "pnpm build",
|
|
});
|
|
|
|
const enabledTargets = await askValue<string[]>(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<string, any> = { ...currentTargets };
|
|
|
|
if (enabledTargets.includes("git")) {
|
|
releaseTargets.git = {
|
|
...(currentTargets.git || {}),
|
|
enabled: true,
|
|
remote: await askValue<string>(interactInstance, {
|
|
type: "input",
|
|
name: "gitRemote",
|
|
message: "Git remote:",
|
|
default: currentTargets.git?.remote || "origin",
|
|
}),
|
|
pushBranch: await askValue<boolean>(interactInstance, {
|
|
type: "confirm",
|
|
name: "pushBranch",
|
|
message: "Push release commit branch?",
|
|
default: currentTargets.git?.pushBranch ?? true,
|
|
}),
|
|
pushTags: await askValue<boolean>(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<string>(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<string>(interactInstance, {
|
|
type: "list",
|
|
name: "npmAccessLevel",
|
|
message: "npm publish access level:",
|
|
choices: ["public", "private"],
|
|
default: currentTargets.npm?.accessLevel || "public",
|
|
}),
|
|
alreadyPublished: await askValue<string>(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<string>(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<void> {
|
|
const findings = await collectDoctorFindings();
|
|
printDoctorResult(findings, mode);
|
|
}
|
|
|
|
async function handleFix(argvArg: any, mode: ICliMode): Promise<void> {
|
|
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;
|
|
}
|
|
|
|
const findings = await collectDoctorFindings();
|
|
const 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 opencode non-interactively.");
|
|
}
|
|
const confirmed = await plugins.smartinteract.SmartInteract.getCliConfirmation(
|
|
`Run opencode to fix .smartconfig.json? (${counts.error} error, ${counts.warn} warning)`,
|
|
true,
|
|
);
|
|
if (!confirmed) {
|
|
plugins.logger.log("info", "Config fix cancelled.");
|
|
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, {
|
|
passthrough: true,
|
|
});
|
|
} 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 collectDoctorFindings(): Promise<IDoctorFinding[]> {
|
|
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<string, any>;
|
|
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 || {}, findings);
|
|
|
|
return findings;
|
|
}
|
|
|
|
/**
|
|
* Handle services configuration
|
|
*/
|
|
async function handleServices(mode: ICliMode): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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 (Array.isArray(targetConfig.images)) {
|
|
details.push(`images=${targetConfig.images.length}`);
|
|
}
|
|
return details.length > 0 ? `${state} (${details.join(", ")})` : state;
|
|
}
|
|
|
|
async function askValue<T>(
|
|
interactInstance: any,
|
|
options: any,
|
|
): Promise<T> {
|
|
const response = await interactInstance.askQuestion(options);
|
|
return response.value as T;
|
|
}
|
|
|
|
async function readPackageJson(): Promise<Record<string, any>> {
|
|
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, any>): 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<TDoctorFindingLevel, number> {
|
|
return findings.reduce(
|
|
(accumulator, finding) => {
|
|
accumulator[finding.level] += 1;
|
|
return accumulator;
|
|
},
|
|
{ ok: 0, warn: 0, error: 0 } as Record<TDoctorFindingLevel, number>,
|
|
);
|
|
}
|
|
|
|
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`.",
|
|
"- 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<string, any>,
|
|
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<string, any>,
|
|
findings: IDoctorFinding[],
|
|
): Promise<void> {
|
|
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<string, any>,
|
|
findings: IDoctorFinding[],
|
|
): Promise<void> {
|
|
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<string, any>,
|
|
findings: IDoctorFinding[],
|
|
): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string, any>,
|
|
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<string, any>,
|
|
findings: IDoctorFinding[],
|
|
): Promise<void> {
|
|
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 <command> [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: "Use opencode to repair .smartconfig.json" },
|
|
{ name: "get <path>", description: "Read a single config value" },
|
|
{ name: "set <path> <value>", description: "Write a config value" },
|
|
{ name: "unset <path>", 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 <setting> <value>",
|
|
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 <command> [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] Use opencode to repair .smartconfig.json");
|
|
console.log(" get <path> Read a single config value");
|
|
console.log(" set <path> <value> Write a config value");
|
|
console.log(" unset <path> 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("");
|
|
}
|