Compare commits

...

6 Commits

Author SHA1 Message Date
jkunz 6f0928e7c7 v2.19.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-13 10:21:25 +00:00
jkunz 26effadcc9 feat(release): delegate docker target to tsdocker 2026-05-13 10:19:56 +00:00
jkunz c38e94bcf3 v2.18.1
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-10 14:41:49 +00:00
jkunz b9b51f29d1 fix(config): use inherited stdio for opencode handoff 2026-05-10 14:41:08 +00:00
jkunz a3ad48368d v2.18.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-10 13:43:05 +00:00
jkunz c10b764c0a feat(config): add opencode config fix 2026-05-10 13:42:57 +00:00
10 changed files with 527 additions and 84 deletions
+2 -1
View File
@@ -63,7 +63,8 @@
},
"docker": {
"enabled": false,
"images": []
"engine": "tsdocker",
"patterns": []
}
}
}
+20
View File
@@ -3,6 +3,26 @@
## Pending
## 2026-05-13 - 2.19.0
### Features
- Delegate Docker release targets to `tsdocker`.
- Replace Docker image template publishing with `tsdocker push` execution.
- Move Docker registry behavior to `@git.zone/tsdocker` config and validate removed image config as invalid.
## 2026-05-10 - 2.18.1
### Fixes
- Run `gitzone config fix` opencode handoff with inherited terminal I/O.
## 2026-05-10 - 2.18.0
### Features
- Add `gitzone config fix` to invoke opencode for configuration repair.
## 2026-05-10 - 2.17.0
### Features
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@git.zone/cli",
"private": false,
"version": "2.17.0",
"version": "2.19.0",
"description": "A comprehensive CLI tool for enhancing and managing local development workflows with gitzone utilities, focusing on project setup, version control, code formatting, and template management.",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
@@ -87,7 +87,7 @@
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartscaf": "^4.0.21",
"@push.rocks/smartshell": "^3.3.7",
"@push.rocks/smartshell": "^3.5.0",
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartupdate": "^2.0.6",
"prettier": "^3.8.1"
+35 -23
View File
@@ -81,8 +81,8 @@ importers:
specifier: ^4.0.21
version: 4.0.21
'@push.rocks/smartshell':
specifier: ^3.3.7
version: 3.3.7
specifier: ^3.5.0
version: 3.5.0
'@push.rocks/smartunique':
specifier: ^3.0.9
version: 3.0.9
@@ -1063,6 +1063,9 @@ packages:
'@push.rocks/smartdelay@3.0.5':
resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==}
'@push.rocks/smartdelay@3.1.0':
resolution: {integrity: sha512-59xveBMbWmbFhh/rqhQnYG/klg/VONG9hV8+RQ7ftqsNRkcmUT+VM5etAbODgAUvsF4lxK+xVR0tbZOo0kGhRQ==}
'@push.rocks/smartdiff@1.1.0':
resolution: {integrity: sha512-AAz/unmko0C+g+60odOoK32PE3Ci3YLoB+zfg1LGLyVRCthcdzjqa1C2Km0MfG7IyJQKPdj8J5HPubtpm3ZeaQ==}
@@ -1192,6 +1195,9 @@ packages:
'@push.rocks/smartpromise@4.2.3':
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==}
'@push.rocks/smartpromise@4.2.4':
resolution: {integrity: sha512-8FUyYt94hOIY9mqHjitn4h69u0jbEtTF2RKKw2DpiTVFjpDTk9gXbVHZ/V+xEcBrN4mrzdQES0OiDmkNPoddEQ==}
'@push.rocks/smartpuppeteer@2.0.5':
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
@@ -1222,8 +1228,8 @@ packages:
'@push.rocks/smartserve@2.0.1':
resolution: {integrity: sha512-YQb2qexfCzCqOlLWBBXKMg6xG4zahCPAxomz/KEKAwHtW6wMTtuHKSTSkRTQ0vl9jssLMAmRz2OyafiL9XGJXQ==}
'@push.rocks/smartshell@3.3.7':
resolution: {integrity: sha512-b3st2+FjHUVhZZRlXfw93+SQA0UMVlURqe55uVpWdjJX7jeGXTTeszuYygtiR99zC5iZ8WZhGDct3N2L1qc/qw==}
'@push.rocks/smartshell@3.5.0':
resolution: {integrity: sha512-Hx9TVvC/AWxZsnm1GDb+W4Fe58nf1FkKbSBABUgkxct4XRYugBI2z9Twnjm3R9vdRry8oy0enfR9NPVhisGaGA==}
'@push.rocks/smartspawn@3.0.3':
resolution: {integrity: sha512-DyrGPV69wwOiJgKkyruk5hS3UEGZ99xFAqBE9O2nM8VXCRLbbty3xt1Ug5Z092ZZmJYaaGMSnMw3ijyZJFCT0Q==}
@@ -3917,9 +3923,9 @@ packages:
engines: {node: '>= 8'}
hasBin: true
which@6.0.1:
resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==}
engines: {node: ^20.17.0 || >=22.9.0}
which@7.0.0:
resolution: {integrity: sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
hasBin: true
wordwrap@1.0.0:
@@ -4802,7 +4808,7 @@ snapshots:
'@push.rocks/smartlog': 3.2.1
'@push.rocks/smartlog-destination-local': 9.0.2
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartshell': 3.3.7
'@push.rocks/smartshell': 3.5.0
'@push.rocks/smarttime': 4.2.3
typedoc: 0.28.17(typescript@5.9.3)
typescript: 5.9.3
@@ -4832,7 +4838,7 @@ snapshots:
'@push.rocks/smartnpm': 2.0.6
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartrequest': 5.0.1
'@push.rocks/smartshell': 3.3.7
'@push.rocks/smartshell': 3.5.0
transitivePeerDependencies:
- '@nuxt/kit'
- aws-crt
@@ -4845,7 +4851,7 @@ snapshots:
'@git.zone/tsrun@2.0.1':
dependencies:
'@push.rocks/smartfile': 13.1.2
'@push.rocks/smartshell': 3.3.7
'@push.rocks/smartshell': 3.5.0
tsx: 4.21.0
'@git.zone/tstest@3.3.2(socks@2.8.7)(typescript@5.9.3)':
@@ -4870,7 +4876,7 @@ snapshots:
'@push.rocks/smartrequest': 5.0.1
'@push.rocks/smarts3': 5.3.0
'@push.rocks/smartserve': 2.0.1
'@push.rocks/smartshell': 3.3.7
'@push.rocks/smartshell': 3.5.0
'@push.rocks/smarttime': 4.2.3
'@push.rocks/smartwatch': 6.3.0
'@types/ws': 8.18.1
@@ -5590,7 +5596,7 @@ snapshots:
'@push.rocks/smartai': 2.0.0(typescript@5.9.3)(ws@8.19.0)(zod@3.25.76)
'@push.rocks/smartfs': 1.5.0
'@push.rocks/smartrequest': 5.0.1
'@push.rocks/smartshell': 3.3.7
'@push.rocks/smartshell': 3.5.0
ai: 6.0.116(zod@3.25.76)
zod: 3.25.76
transitivePeerDependencies:
@@ -5775,6 +5781,10 @@ snapshots:
dependencies:
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartdelay@3.1.0':
dependencies:
'@push.rocks/smartpromise': 4.2.4
'@push.rocks/smartdiff@1.1.0':
dependencies:
diff: 8.0.3
@@ -5818,7 +5828,7 @@ snapshots:
'@push.rocks/smartexit@2.0.3':
dependencies:
'@push.rocks/lik': 6.3.1
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartpromise': 4.2.4
'@push.rocks/smartexpect@2.5.0':
dependencies:
@@ -5897,7 +5907,7 @@ snapshots:
'@push.rocks/smartfile': 11.2.7
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartshell': 3.3.7
'@push.rocks/smartshell': 3.5.0
'@push.rocks/smartstring': 4.1.0
'@push.rocks/smarttime': 4.2.3
'@types/diff': 8.0.0
@@ -5968,7 +5978,7 @@ snapshots:
'@push.rocks/smartmustache': 3.0.2
'@push.rocks/smartpnpm': 1.0.6
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartshell': 3.3.7
'@push.rocks/smartshell': 3.5.0
'@tsclass/tsclass': 4.4.4
transitivePeerDependencies:
- supports-color
@@ -6159,14 +6169,16 @@ snapshots:
'@push.rocks/smartpnpm@1.0.6':
dependencies:
'@push.rocks/smartshell': 3.3.7
'@push.rocks/smartshell': 3.5.0
'@push.rocks/smartpromise@4.2.3': {}
'@push.rocks/smartpromise@4.2.4': {}
'@push.rocks/smartpuppeteer@2.0.5(typescript@5.9.3)':
dependencies:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartshell': 3.3.7
'@push.rocks/smartshell': 3.5.0
puppeteer: 24.35.0(typescript@5.9.3)
tree-kill: 1.2.2
transitivePeerDependencies:
@@ -6233,7 +6245,7 @@ snapshots:
'@push.rocks/smartinteract': 2.0.16
'@push.rocks/smartobject': 1.0.12
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartshell': 3.3.7
'@push.rocks/smartshell': 3.5.0
'@push.rocks/smartyaml': 3.0.4
'@push.rocks/smartserve@2.0.1':
@@ -6249,13 +6261,13 @@ snapshots:
- bufferutil
- utf-8-validate
'@push.rocks/smartshell@3.3.7':
'@push.rocks/smartshell@3.5.0':
dependencies:
'@push.rocks/smartdelay': 3.0.5
'@push.rocks/smartdelay': 3.1.0
'@push.rocks/smartexit': 2.0.3
'@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartpromise': 4.2.4
'@types/which': 3.0.4
which: 6.0.1
which: 7.0.0
'@push.rocks/smartspawn@3.0.3':
dependencies:
@@ -9513,7 +9525,7 @@ snapshots:
dependencies:
isexe: 2.0.0
which@6.0.1:
which@7.0.0:
dependencies:
isexe: 4.0.0
+23 -4
View File
@@ -147,7 +147,7 @@ Targets decide what happens after that:
| --- | --- |
| `git` | Pushes the release commit and tags, often triggering remote CI release builds |
| `npm` | Publishes the package to configured npm registries |
| `docker` | Builds and pushes configured Docker images |
| `docker` | Delegates container builds and pushes to `tsdocker` |
```bash
# Preview the resolved release plan
@@ -205,7 +205,7 @@ The standard buckets are `Breaking Changes`, `Features`, `Fixes`, `Documentation
## Configuration
All CLI config lives under `@git.zone/cli` in `.smartconfig.json`.
CLI workflow config lives under `@git.zone/cli` in `.smartconfig.json`. Docker build and registry behavior lives under `@git.zone/tsdocker` and is used by the Docker release target.
```json
{
@@ -237,11 +237,21 @@ All CLI config lives under `@git.zone/cli` in `.smartconfig.json`.
"alreadyPublished": "success"
},
"docker": {
"enabled": false,
"images": []
"enabled": true,
"engine": "tsdocker",
"patterns": [],
"cached": true,
"parallel": true
}
}
}
},
"@git.zone/tsdocker": {
"registries": ["registry.gitlab.com"],
"registryRepoMap": {
"registry.gitlab.com": "myorg/myproject"
},
"platforms": ["linux/amd64", "linux/arm64"]
}
}
```
@@ -252,6 +262,12 @@ NPM registries belong only here:
@git.zone/cli.release.targets.npm.registries
```
Docker registries belong only here and should be registry hosts without `http://` or `https://`:
```text
@git.zone/tsdocker.registries
```
Useful config commands:
```bash
@@ -266,6 +282,9 @@ gitzone config release
# Validate schema, legacy keys, release targets, registries, and npm auth
gitzone config doctor
# Use opencode to repair configuration issues found by doctor
gitzone config fix
# Read the npm release target registries
gitzone config get release.targets.npm.registries
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@git.zone/cli',
version: '2.17.0',
version: '2.19.0',
description: 'A comprehensive CLI tool for enhancing and managing local development workflows with gitzone utilities, focusing on project setup, version control, code formatting, and template management.'
}
+20 -2
View File
@@ -68,8 +68,10 @@ const migrateToV2 = (smartconfigJson: Record<string, any>): boolean => {
migrated = true;
}
if (isPlainObject(releaseConfig.docker) && !isPlainObject(targets.docker)) {
targets.docker = releaseConfig.docker;
if (isPlainObject(releaseConfig.docker)) {
targets.docker = isPlainObject(targets.docker)
? { ...releaseConfig.docker, ...targets.docker }
: releaseConfig.docker;
delete releaseConfig.docker;
migrated = true;
}
@@ -141,11 +143,27 @@ const migrateToV2 = (smartconfigJson: Record<string, any>): boolean => {
if (dockerTarget.enabled === undefined) {
dockerTarget.enabled = true;
}
dockerTarget.engine = "tsdocker";
}
delete releaseConfig.steps;
migrated = true;
}
if (isPlainObject(targets.docker)) {
if (targets.docker.images) {
delete targets.docker.images;
migrated = true;
}
if (targets.docker.engine !== "tsdocker") {
targets.docker.engine = "tsdocker";
migrated = true;
}
if (!Array.isArray(targets.docker.patterns)) {
targets.docker.patterns = [];
migrated = true;
}
}
if (releaseConfig.changelog) {
delete releaseConfig.changelog;
migrated = true;
+20 -3
View File
@@ -52,7 +52,12 @@ export interface IReleaseNpmTargetConfig {
export interface IReleaseDockerTargetConfig {
enabled?: boolean;
images?: string[];
engine?: "tsdocker";
patterns?: string[];
cached?: boolean;
parallel?: boolean | number;
context?: string;
noBuild?: boolean;
}
export interface IReleaseWorkflowConfig {
@@ -109,7 +114,12 @@ export interface IResolvedReleaseWorkflow {
npmAccessLevel: "public" | "private";
npmAlreadyPublished: "success" | "error";
dockerEnabled: boolean;
dockerImages: string[];
dockerEngine: "tsdocker";
dockerPatterns: string[];
dockerCached: boolean;
dockerParallel: boolean | number;
dockerContext?: string;
dockerNoBuild: boolean;
}
interface ICliWorkflowConfig {
@@ -382,6 +392,13 @@ export const resolveReleaseWorkflow = async (argvArg: any): Promise<IResolvedRel
npmAccessLevel: npmConfig.accessLevel || "public",
npmAlreadyPublished: npmConfig.alreadyPublished || "success",
dockerEnabled,
dockerImages: dockerConfig.images || [],
dockerEngine: "tsdocker",
dockerPatterns: Array.isArray(dockerConfig.patterns) ? dockerConfig.patterns : [],
dockerCached: dockerConfig.cached ?? false,
dockerParallel: dockerConfig.parallel ?? false,
dockerContext: typeof dockerConfig.context === "string" && dockerConfig.context.trim()
? dockerConfig.context.trim()
: undefined,
dockerNoBuild: dockerConfig.noBuild ?? false,
};
};
+358 -25
View File
@@ -117,6 +117,9 @@ export const run = async (argvArg: any) => {
case "doctor":
await handleDoctor(mode);
break;
case "fix":
await handleFix(argvArg, mode);
break;
case "migrate":
await handleMigrate(value, mode);
break;
@@ -165,6 +168,7 @@ async function handleInteractiveMenu(): Promise<void> {
{ 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" },
@@ -213,6 +217,9 @@ async function handleInteractiveMenu(): Promise<void> {
case "doctor":
await handleDoctor(defaultCliMode);
break;
case "fix":
await handleFix({ _: ["config", "fix"] }, defaultCliMode);
break;
case "help":
showHelp();
break;
@@ -786,7 +793,7 @@ async function handleRelease(mode: ICliMode): Promise<void> {
choices: [
{ name: "git - push branch and tags", value: "git" },
{ name: "npm - publish package registries", value: "npm" },
{ name: "docker - build and push images", value: "docker" },
{ name: "docker - build and push through tsdocker", value: "docker" },
],
default: getDefaultEnabledTargets(currentTargets),
});
@@ -853,21 +860,49 @@ async function handleRelease(mode: ICliMode): Promise<void> {
}
if (enabledTargets.includes("docker")) {
const images = await askValue<string>(interactInstance, {
const patterns = 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(", ")
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<boolean>(interactInstance, {
type: "confirm",
name: "dockerCached",
message: "Use tsdocker cached builds?",
default: currentTargets.docker?.cached ?? false,
});
const parallel = await askValue<string>(interactInstance, {
type: "input",
name: "dockerParallel",
message: "tsdocker parallel mode (false, true, or concurrency number):",
default: formatDockerParallel(currentTargets.docker?.parallel ?? false),
});
const context = await askValue<string>(interactInstance, {
type: "input",
name: "dockerContext",
message: "Docker context for tsdocker (empty for default):",
default: currentTargets.docker?.context || "",
});
const noBuild = await askValue<boolean>(interactInstance, {
type: "confirm",
name: "dockerNoBuild",
message: "Skip tsdocker build and only push existing local registry images?",
default: currentTargets.docker?.noBuild ?? false,
});
releaseTargets.docker = {
...(currentTargets.docker || {}),
enabled: true,
images: parseCsv(images),
engine: "tsdocker",
patterns: parseCsv(patterns),
cached,
parallel: parseDockerParallel(parallel),
context: context.trim() || undefined,
noBuild,
};
} else {
releaseTargets.docker = { ...(currentTargets.docker || {}), enabled: false };
releaseTargets.docker = { enabled: false, engine: "tsdocker" };
}
setCliConfigValueInData(smartconfigData, "schemaVersion", CURRENT_GITZONE_CLI_SCHEMA_VERSION);
@@ -890,6 +925,86 @@ async function handleRelease(mode: ICliMode): Promise<void> {
}
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, {
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 collectDoctorFindings(): Promise<IDoctorFinding[]> {
const findings: IDoctorFinding[] = [];
const smartconfigPath = getSmartconfigPath();
const smartconfigExists = await plugins.smartfs.file(smartconfigPath).exists();
@@ -900,7 +1015,7 @@ async function handleDoctor(mode: ICliMode): Promise<void> {
message: ".smartconfig.json does not exist",
fix: "Run `gitzone config project` to create project basics.",
});
return printDoctorResult(findings, mode);
return findings;
}
let smartconfigData: Record<string, any>;
@@ -912,7 +1027,7 @@ async function handleDoctor(mode: ICliMode): Promise<void> {
message: ".smartconfig.json is not valid JSON",
fix: error instanceof Error ? error.message : String(error),
});
return printDoctorResult(findings, mode);
return findings;
}
const cliConfig = getCliConfigValueFromData(smartconfigData, "") || {};
@@ -956,9 +1071,9 @@ async function handleDoctor(mode: ICliMode): Promise<void> {
await validateDetectedProjectType(cliConfig, findings);
validateCommitConfig(cliConfig.commit || {}, findings);
await validateReleaseConfig(cliConfig.release || {}, findings);
await validateReleaseConfig(cliConfig.release || {}, smartconfigData, findings);
printDoctorResult(findings, mode);
return findings;
}
/**
@@ -1204,9 +1319,14 @@ function formatTarget(enabled: unknown, targetConfig: any): string {
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}`);
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;
}
@@ -1251,6 +1371,29 @@ function parseCsv(value: string): string[] {
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://")) {
@@ -1274,14 +1417,57 @@ function getDefaultEnabledTargets(currentTargets: Record<string, any>): string[]
return enabledTargets;
}
function printDoctorResult(findings: IDoctorFinding[], mode: ICliMode): void {
const counts = findings.reduce(
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`.",
"- 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({
@@ -1385,6 +1571,7 @@ function validateCommitConfig(
async function validateReleaseConfig(
releaseConfig: Record<string, any>,
smartconfigData: Record<string, any>,
findings: IDoctorFinding[],
): Promise<void> {
const confirmation = releaseConfig.confirmation;
@@ -1425,7 +1612,7 @@ async function validateReleaseConfig(
const targets = releaseConfig.targets || {};
await validateGitTarget(targets.git || {}, findings);
await validateNpmTarget(targets.npm || {}, findings);
validateDockerTarget(targets.docker || {}, findings);
await validateDockerTarget(targets.docker || {}, smartconfigData, findings);
}
async function validateGitTarget(
@@ -1589,31 +1776,171 @@ async function validateNpmAuth(
}
}
function validateDockerTarget(
async function validateDockerTarget(
dockerTarget: Record<string, any>,
smartconfigData: Record<string, any>,
findings: IDoctorFinding[],
): void {
): Promise<void> {
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 (!Array.isArray(dockerTarget.images) || dockerTarget.images.length === 0) {
if (dockerTarget.engine !== "tsdocker") {
findings.push({
level: "error",
message: "Docker release target is enabled without images",
fix: "Set release.targets.docker.images or disable release.targets.docker.enabled.",
message: "Docker release target must use tsdocker",
fix: "Set release.targets.docker.engine to tsdocker.",
});
return;
}
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<string, any> {
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<string, any>,
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: `Docker release target has ${dockerTarget.images.length} image template(s)`,
message: `@git.zone/tsdocker has ${registries.length} registries`,
});
}
async function validateTsdockerCommand(findings: IDoctorFinding[]): Promise<void> {
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<string, any>,
findings: IDoctorFinding[],
@@ -1666,6 +1993,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: "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" },
@@ -1689,6 +2017,8 @@ export function showHelp(mode?: ICliMode): void {
"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",
@@ -1708,6 +2038,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(" get <path> Read a single config value");
console.log(" set <path> <value> Write a config value");
console.log(" unset <path> Delete a config value");
@@ -1730,6 +2061,8 @@ export function showHelp(mode?: ICliMode): void {
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");
+45 -22
View File
@@ -107,7 +107,7 @@ export const run = async (argvArg: any) => {
npmResults.push(...(await runNpmTarget(smartshellInstance, workflow)));
}
if (workflow.targets.includes("docker")) {
dockerResults.push(...(await runDockerTarget(smartshellInstance, workflow, newVersion)));
dockerResults.push(...(await runDockerTarget(smartshellInstance, workflow)));
}
printReleaseSummary(newVersion, gitResults, npmResults, dockerResults);
@@ -262,31 +262,43 @@ async function runNpmTarget(
async function runDockerTarget(
smartshellInstance: plugins.smartshell.Smartshell,
workflow: IResolvedReleaseWorkflow,
newVersion: string,
): Promise<ITargetResult[]> {
if (!workflow.dockerEnabled) {
return [{ target: "docker", status: "skipped", message: "disabled" }];
}
if (workflow.dockerImages.length === 0) {
return [{ target: "docker", status: "failed", message: "no images configured" }];
}
const results: ITargetResult[] = [];
for (const imageTemplate of workflow.dockerImages) {
const image = imageTemplate.replaceAll("{{version}}", newVersion);
const buildResult = await smartshellInstance.exec(`docker build -t ${shellQuote(image)} .`);
if (buildResult.exitCode !== 0) {
results.push({ target: image, status: "failed", message: "docker build failed" });
continue;
const command = buildTsdockerPushCommand(workflow);
const result = await smartshellInstance.exec(command);
const output = `${result.stdout || ""}\n${(result as any).stderr || ""}\n${(result as any).combinedOutput || ""}`;
return [{
target: workflow.dockerPatterns.length > 0
? `tsdocker:${workflow.dockerPatterns.join(",")}`
: "tsdocker",
status: result.exitCode === 0 ? "success" : "failed",
message: result.exitCode === 0 ? undefined : firstMeaningfulLine(output),
}];
}
function buildTsdockerPushCommand(workflow: IResolvedReleaseWorkflow): string {
const commandParts = ["tsdocker", "push"];
if (workflow.dockerNoBuild) {
commandParts.push("--no-build");
}
const pushResult = await smartshellInstance.exec(`docker push ${shellQuote(image)}`);
results.push({
target: image,
status: pushResult.exitCode === 0 ? "success" : "failed",
message: pushResult.exitCode === 0 ? undefined : "docker push failed",
});
if (workflow.dockerCached) {
commandParts.push("--cached");
}
return results;
if (workflow.dockerParallel === true) {
commandParts.push("--parallel");
} else if (typeof workflow.dockerParallel === "number" && Number.isFinite(workflow.dockerParallel) && workflow.dockerParallel > 0) {
commandParts.push(`--parallel=${Math.floor(workflow.dockerParallel)}`);
}
if (workflow.dockerContext) {
commandParts.push(`--context=${shellQuote(workflow.dockerContext)}`);
}
for (const pattern of workflow.dockerPatterns) {
commandParts.push(shellQuote(pattern));
}
return commandParts.join(" ");
}
function isAlreadyPublishedOutput(output: string): boolean {
@@ -315,11 +327,22 @@ function printReleasePlan(workflow: IResolvedReleaseWorkflow): void {
console.log(`npm registries: ${workflow.npmRegistries.length > 0 ? workflow.npmRegistries.join(", ") : "none"}`);
}
if (workflow.targets.includes("docker")) {
console.log(`docker images: ${workflow.dockerImages.length > 0 ? workflow.dockerImages.join(", ") : "none"}`);
console.log(`docker engine: ${workflow.dockerEngine}`);
console.log(`docker patterns: ${workflow.dockerPatterns.length > 0 ? workflow.dockerPatterns.join(", ") : "all Dockerfiles"}`);
console.log(`docker options: ${formatDockerOptions(workflow)}`);
}
console.log("");
}
function formatDockerOptions(workflow: IResolvedReleaseWorkflow): string {
const options: string[] = [];
if (workflow.dockerCached) options.push("cached");
if (workflow.dockerParallel) options.push(`parallel=${workflow.dockerParallel === true ? "true" : workflow.dockerParallel}`);
if (workflow.dockerNoBuild) options.push("no-build");
if (workflow.dockerContext) options.push(`context=${workflow.dockerContext}`);
return options.length > 0 ? options.join(", ") : "default";
}
function printReleaseSummary(
newVersion: string,
gitResults: ITargetResult[],
@@ -365,7 +388,7 @@ export function showHelp(mode?: ICliMode): void {
{ flag: "-p, --push", description: "Enable the git release target" },
{ flag: "--target <names>", description: "Release only selected targets: git,npm,docker" },
{ flag: "--npm", description: "Enable the npm release target" },
{ flag: "--docker", description: "Enable the Docker release target" },
{ flag: "--docker", description: "Enable the tsdocker release target" },
{ flag: "--no-publish", description: "Run release core and git target only" },
{ flag: "--plan", description: "Show resolved workflow without mutating files" },
],
@@ -385,7 +408,7 @@ export function showHelp(mode?: ICliMode): void {
console.log(" -p, --push Enable the git release target");
console.log(" --target <names> Release only selected targets: git,npm,docker");
console.log(" --npm Enable the npm release target");
console.log(" --docker Enable the Docker release target");
console.log(" --docker Enable the tsdocker release target");
console.log(" --no-publish Run release core and git target only");
console.log(" --major|--minor|--patch Override inferred semver level");
console.log(" --plan Show resolved workflow without mutating files");