Compare commits

...

13 Commits

Author SHA1 Message Date
jkunz ad7f2742ff v2.19.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-14 13:26:17 +00:00
jkunz b234ecc12a fix(config): migrate legacy release arrays during config fixes and validate release config shape 2026-05-14 13:25:56 +00:00
jkunz 278df40ba7 feat(format): add check and fix workflows 2026-05-14 13:18:49 +00:00
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
jkunz 7686504e4e v2.17.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:09:36 +00:00
jkunz d96b220703 feat(config): add guided configuration flows 2026-05-10 13:09:28 +00:00
jkunz 06f2de3230 v2.16.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 11:11:03 +00:00
jkunz cc3128f07b fix(cli): guard startup update check 2026-05-10 11:10:30 +00:00
20 changed files with 2246 additions and 342 deletions
+2 -1
View File
@@ -63,7 +63,8 @@
},
"docker": {
"enabled": false,
"images": []
"engine": "tsdocker",
"patterns": []
}
}
}
+43
View File
@@ -3,6 +3,49 @@
## Pending
## 2026-05-14 - 2.19.1
### Fixes
- migrate legacy release arrays during config fixes and validate release config shape (config)
- Automatically converts legacy release registry arrays into release.targets.npm.registries during smartconfig migration and config fix runs.
- Re-runs doctor checks after applying known migrations so resolved issues do not require the external fixer.
- Reports an explicit validation error when release config is not an object.
- Updates config fix prompts and help text to use generic configuration repair wording.
## 2026-05-13 - 2.19.0
### Features
- 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
- Add guided project, CLI, release, and doctor flows to `gitzone config`.
## 2026-05-10 - 2.16.1
### Fixes
- Prevent startup update checks from crashing when installed package metadata is incomplete.
## 2026-05-10 - 2.16.0
### Features
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@git.zone/cli",
"private": false,
"version": "2.16.0",
"version": "2.19.1",
"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
+10
View File
@@ -193,12 +193,22 @@ gitzone format
# Read-only JSON plan
gitzone format plan --json
# CI-friendly check, exits non-zero when changes or validator errors remain
gitzone format check
# Dry run to preview changes
gitzone format --dry-run
# Limit formatter modules
gitzone format --only prettier,packagejson
gitzone format --skip license
# Non-interactive apply
gitzone format --write --yes
# Deterministic format first, opencode for remaining issues
gitzone format fix
# Plan only (no execution)
gitzone format --plan-only
+43 -5
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,12 +262,29 @@ 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
# Show current @git.zone/cli config
gitzone config show --json
# Configure project basics, CLI behavior, and release targets interactively
gitzone config project
gitzone config cli
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
@@ -282,15 +309,26 @@ gitzone format
# Emit a machine-readable plan
gitzone format plan --json
# Fail when formatting changes or validator errors remain
gitzone format check
# Run a subset of formatters
gitzone format --only prettier,packagejson
# Apply changes
gitzone format --write
# Apply without prompt
gitzone format --write --yes
# Apply deterministic fixes, then use opencode for remaining issues
gitzone format fix
```
Formatters include cleanup, smartconfig normalization, dependency license checks, package metadata normalization, template updates, `.gitignore`, TypeScript config, Prettier, README existence checks, and configured copy operations.
`gitzone format fix` intentionally lives outside the default format path. Normal format runs stay deterministic; the fix command uses opencode only after deterministic formatters have done what they can.
## Development Services
`gitzone services` manages local Docker-backed services for development projects.
@@ -404,7 +442,7 @@ gitzone config show --json
## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./license) file.
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@git.zone/cli',
version: '2.16.0',
version: '2.19.1',
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.'
}
+102 -132
View File
@@ -1,167 +1,137 @@
import * as plugins from "./plugins.js";
import * as paths from "./paths.js";
import { GitzoneConfig } from "./classes.gitzoneconfig.js";
import { getRawCliMode } from "./helpers.climode.js";
import {
getProcessUserArgv,
getRawCliMode,
parseCliArgv,
} from "./helpers.climode.js";
import { commitinfo } from "./00_commitinfo_data.js";
const gitzoneSmartcli = new plugins.smartcli.Smartcli();
const runParsedCommand = async (argvArg: any): Promise<void> => {
const command = argvArg._?.[0];
export let run = async () => {
const done = plugins.smartpromise.defer();
const rawCliMode = await getRawCliMode();
// get packageInfo
const projectInfo = new plugins.projectinfo.ProjectInfo(paths.packageDir);
// check for updates
if (rawCliMode.checkUpdates) {
const smartupdateInstance = new plugins.smartupdate.SmartUpdate();
await smartupdateInstance.check(
"gitzone",
projectInfo.npm.version,
"http://gitzone.gitlab.io/gitzone/changelog.html",
);
}
if (rawCliMode.output === "human") {
console.log("---------------------------------------------");
}
gitzoneSmartcli.addVersion(projectInfo.npm.version);
// ======> Standard task <======
/**
* standard task
*/
gitzoneSmartcli.standardCommand().subscribe(async (argvArg) => {
switch (command) {
case undefined:
case "help": {
const modStandard = await import("./mod_standard/index.js");
await modStandard.run(argvArg);
});
gitzoneSmartcli.addCommand("help").subscribe(async (argvArg) => {
const modStandard = await import("./mod_standard/index.js");
await modStandard.run(argvArg);
});
// ======> Specific tasks <======
/**
* commit something
*/
gitzoneSmartcli.addCommand("commit").subscribe(async (argvArg) => {
break;
}
case "commit": {
const modCommit = await import("./mod_commit/index.js");
await modCommit.run(argvArg);
});
/**
* create a release from pending changelog entries
*/
gitzoneSmartcli.addCommand("release").subscribe(async (argvArg) => {
break;
}
case "release": {
const modRelease = await import("./mod_release/index.js");
await modRelease.run(argvArg);
});
/**
* deprecate a package on npm
*/
gitzoneSmartcli.addCommand("deprecate").subscribe(async (argvArg) => {
break;
}
case "deprecate": {
const modDeprecate = await import("./mod_deprecate/index.js");
await modDeprecate.run();
});
/**
* docker
*/
gitzoneSmartcli.addCommand("docker").subscribe(async (argvArg) => {
break;
}
case "docker": {
const modDocker = await import("./mod_docker/index.js");
await modDocker.run(argvArg);
});
/**
* Update all files that comply with the gitzone standard
*/
gitzoneSmartcli.addCommand("format").subscribe(async (argvArg) => {
const config = GitzoneConfig.fromCwd();
break;
}
case "format": {
const modFormat = await import("./mod_format/index.js");
// Handle format with options
// Default is dry-mode, use --write/-w to apply changes
await modFormat.run({
...argvArg,
write: argvArg.write || argvArg.w,
dryRun: argvArg["dry-run"],
yes: argvArg.yes,
planOnly: argvArg["plan-only"],
savePlan: argvArg["save-plan"],
fromPlan: argvArg["from-plan"],
yes: argvArg.yes || argvArg.y,
planOnly: argvArg["plan-only"] || argvArg.planOnly,
savePlan: argvArg["save-plan"] || argvArg.savePlan,
fromPlan: argvArg["from-plan"] || argvArg.fromPlan,
detailed: argvArg.detailed,
interactive: argvArg.interactive !== false,
verbose: argvArg.verbose,
diff: argvArg.diff,
});
});
/**
* run meta commands
*/
gitzoneSmartcli.addCommand("meta").subscribe(async (argvArg) => {
const config = GitzoneConfig.fromCwd();
break;
}
case "meta": {
const modMeta = await import("./mod_meta/index.js");
modMeta.run(argvArg);
});
/**
* open assets
*/
gitzoneSmartcli.addCommand("open").subscribe(async (argvArg) => {
await modMeta.run(argvArg);
break;
}
case "open": {
const modOpen = await import("./mod_open/index.js");
modOpen.run(argvArg);
});
/**
* add a readme to a project
*/
gitzoneSmartcli.addCommand("template").subscribe(async (argvArg) => {
await modOpen.run(argvArg);
break;
}
case "template": {
const modTemplate = await import("./mod_template/index.js");
modTemplate.run(argvArg);
});
/**
* start working on a project
*/
gitzoneSmartcli.addCommand("start").subscribe(async (argvArg) => {
const modTemplate = await import("./mod_start/index.js");
modTemplate.run(argvArg);
});
gitzoneSmartcli.addCommand("helpers").subscribe(async (argvArg) => {
await modTemplate.run(argvArg);
break;
}
case "start": {
const modStart = await import("./mod_start/index.js");
await modStart.run(argvArg);
break;
}
case "helpers": {
const modHelpers = await import("./mod_helpers/index.js");
modHelpers.run(argvArg);
});
/**
* manage the global @git.zone toolchain
*/
gitzoneSmartcli.addCommand("tools").subscribe(async (argvArg) => {
await modHelpers.run(argvArg);
break;
}
case "tools": {
const modTools = await import("./mod_tools/index.js");
await modTools.run(argvArg);
});
/**
* manage release configuration
*/
gitzoneSmartcli.addCommand("config").subscribe(async (argvArg) => {
break;
}
case "config": {
const modConfig = await import("./mod_config/index.js");
await modConfig.run(argvArg);
});
/**
* manage development services (MongoDB, S3/MinIO)
*/
gitzoneSmartcli.addCommand("services").subscribe(async (argvArg) => {
break;
}
case "services": {
const modServices = await import("./mod_services/index.js");
await modServices.run(argvArg);
});
// start parsing of the cli
gitzoneSmartcli.startParse();
return await done.promise;
break;
}
default: {
const modStandard = await import("./mod_standard/index.js");
await modStandard.run(argvArg);
}
}
};
export let run = async () => {
const rawCliMode = await getRawCliMode();
// get packageInfo
const projectInfo = new plugins.projectinfo.ProjectInfo(paths.packageDir);
const projectInfoVersion = (projectInfo.npm as any)?.version;
const packageVersion =
typeof projectInfoVersion === "string" && projectInfoVersion.length > 0
? projectInfoVersion
: commitinfo.version;
// check for updates
if (rawCliMode.checkUpdates) {
const smartupdateInstance = new plugins.smartupdate.SmartUpdate();
try {
await smartupdateInstance.check(
"gitzone",
packageVersion,
"http://gitzone.gitlab.io/gitzone/changelog.html",
);
} catch {
// Update checks must never block actual CLI commands.
}
}
if (rawCliMode.output === "human") {
console.log("---------------------------------------------");
}
const argvArg = parseCliArgv(getProcessUserArgv());
if (argvArg.v || argvArg.version) {
console.log(packageVersion);
return;
}
await runParsedCommand(argvArg);
};
+36 -1
View File
@@ -88,6 +88,41 @@ const parseRawArgv = (argv: string[]): TArgSource => {
return parsedArgv;
};
export const parseCliArgv = parseRawArgv;
export const getProcessUserArgv = (): string[] => {
const rawArgv = process.argv;
const argv0Base = (rawArgv[0] || "").split(/[\\/]/).pop()?.toLowerCase();
const runtimeNames = new Set([
"node",
"node.exe",
"nodejs",
"nodejs.exe",
"bun",
"bun.exe",
"deno",
"deno.exe",
"tsx",
"tsx.exe",
"ts-node",
"ts-node.exe",
]);
if (!runtimeNames.has(argv0Base || "")) {
return rawArgv.slice();
}
const firstUserArg = rawArgv[1] || "";
const firstUserArgLooksLikeScript =
firstUserArg.includes("/") ||
firstUserArg.endsWith(".js") ||
firstUserArg.endsWith(".ts") ||
firstUserArg.endsWith(".mjs") ||
firstUserArg.endsWith(".cjs");
return rawArgv.slice(firstUserArgLooksLikeScript ? 2 : 1);
};
const normalizeOutputMode = (value: unknown): TCliOutputMode | undefined => {
if (value === "human" || value === "plain" || value === "json") {
return value;
@@ -171,7 +206,7 @@ export const getCliMode = async (
export const getRawCliMode = async (): Promise<ICliMode> => {
const cliConfig = await getCliModeConfig();
const rawArgv = parseRawArgv(process.argv.slice(2));
const rawArgv = parseRawArgv(getProcessUserArgv());
return resolveCliMode(rawArgv, cliConfig);
};
+57 -3
View File
@@ -19,6 +19,38 @@ const ensureObject = (parent: Record<string, any>, key: string): Record<string,
return parent[key];
};
const normalizeRegistryList = (registries: unknown[]): string[] => {
const result: string[] = [];
for (const registry of registries) {
if (typeof registry !== "string" || !registry.trim()) {
continue;
}
const normalizedRegistry = normalizeRegistryUrl(registry);
if (!result.includes(normalizedRegistry)) {
result.push(normalizedRegistry);
}
}
return result;
};
const migrateLegacyReleaseArray = (smartconfigJson: Record<string, any>): boolean => {
const cliConfig = ensureObject(smartconfigJson, CLI_NAMESPACE);
if (!Array.isArray(cliConfig.release)) {
return false;
}
const registries = normalizeRegistryList(cliConfig.release);
cliConfig.release = {
targets: {
npm: {
enabled: registries.length > 0,
registries,
},
},
};
return true;
};
const migrateNamespaceKeys = (smartconfigJson: Record<string, any>): boolean => {
let migrated = false;
const migrations = [
@@ -50,9 +82,9 @@ const migrateNamespaceKeys = (smartconfigJson: Record<string, any>): boolean =>
const migrateToV2 = (smartconfigJson: Record<string, any>): boolean => {
const cliConfig = ensureObject(smartconfigJson, CLI_NAMESPACE);
let migrated = migrateLegacyReleaseArray(smartconfigJson);
const releaseConfig = ensureObject(cliConfig, "release");
let migrated = false;
const targets = ensureObject(releaseConfig, "targets");
const shipzoneConfig = smartconfigJson["@ship.zone/szci"];
@@ -68,8 +100,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 +175,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;
@@ -174,6 +224,10 @@ export const migrateSmartconfigData = (
const fromVersion = typeof cliConfig.schemaVersion === "number" ? cliConfig.schemaVersion : 1;
let currentVersion = fromVersion;
if (targetVersion >= 2) {
migrated = migrateLegacyReleaseArray(smartconfigJson) || migrated;
}
if (currentVersion < 2 && targetVersion >= 2) {
migrated = migrateToV2(smartconfigJson) || migrated;
currentVersion = 2;
+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,
};
};
+1357 -25
View File
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -1,6 +1,10 @@
import * as plugins from './mod.plugins.js';
import { FormatContext } from './classes.formatcontext.js';
import type { IPlannedChange, ICheckResult } from './interfaces.format.js';
import type {
IPlannedChange,
ICheckResult,
IFormatWarning,
} from './interfaces.format.js';
import { Project } from '../classes.project.js';
import { FormatStats } from './classes.formatstats.js';
@@ -19,6 +23,14 @@ export abstract class BaseFormatter {
abstract analyze(): Promise<IPlannedChange[]>;
abstract applyChange(change: IPlannedChange): Promise<void>;
get runsWithoutChanges(): boolean {
return false;
}
async validate(): Promise<IFormatWarning[]> {
return [];
}
async execute(changes: IPlannedChange[]): Promise<void> {
const startTime = this.stats.moduleStartTime(this.name);
this.stats.startModule(this.name);
+66 -4
View File
@@ -1,7 +1,11 @@
import * as plugins from './mod.plugins.js';
import { FormatContext } from './classes.formatcontext.js';
import { BaseFormatter } from './classes.baseformatter.js';
import type { IFormatPlan, IPlannedChange } from './interfaces.format.js';
import type {
IFormatPlan,
IPlannedChange,
IFormatWarning,
} from './interfaces.format.js';
import { getModuleIcon } from './interfaces.format.js';
import { logger } from '../gitzone.logging.js';
import { DiffReporter } from './classes.diffreporter.js';
@@ -42,15 +46,21 @@ export class FormatPlanner {
break;
}
}
const warnings = await module.validate();
plan.warnings.push(...warnings);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
plan.warnings.push({
level: 'error',
message: `Failed to analyze module ${module.name}: ${error.message}`,
message: `Failed to analyze module ${module.name}: ${errorMessage}`,
module: module.name,
});
}
}
plan.warnings.push(...this.detectConflictingChanges(plan.changes));
plan.summary.totalFiles =
plan.summary.filesAdded +
plan.summary.filesModified +
@@ -65,11 +75,12 @@ export class FormatPlanner {
context: FormatContext,
): Promise<void> {
const startTime = Date.now();
const changesByModule = this.groupChangesByModule(plan.changes);
for (const module of modules) {
const changes = this.plannedChanges.get(module.name) || [];
const changes = changesByModule.get(module.name) || [];
if (changes.length > 0) {
if (changes.length > 0 || module.runsWithoutChanges) {
logger.log('info', `Executing ${module.name} formatter...`);
await module.execute(changes);
}
@@ -138,4 +149,55 @@ export class FormatPlanner {
return '❌';
}
}
private groupChangesByModule(
changes: IPlannedChange[],
): Map<string, IPlannedChange[]> {
const changesByModule = new Map<string, IPlannedChange[]>();
for (const change of changes) {
const moduleChanges = changesByModule.get(change.module) || [];
moduleChanges.push(change);
changesByModule.set(change.module, moduleChanges);
}
return changesByModule;
}
private detectConflictingChanges(
changes: IPlannedChange[],
): IFormatWarning[] {
const warnings: IFormatWarning[] = [];
const changesByPath = new Map<string, IPlannedChange[]>();
for (const change of changes) {
if (!change.path || change.path === '<various files>') {
continue;
}
const pathChanges = changesByPath.get(change.path) || [];
pathChanges.push(change);
changesByPath.set(change.path, pathChanges);
}
for (const [path, pathChanges] of changesByPath) {
const modules = [...new Set(pathChanges.map((change) => change.module))];
if (modules.length < 2) {
continue;
}
const hasDelete = pathChanges.some((change) => change.type === 'delete');
const plannedContents = pathChanges
.map((change) => change.content)
.filter((content): content is string => content !== undefined);
const uniqueContents = new Set(plannedContents);
const level = hasDelete || uniqueContents.size > 1 ? 'warning' : 'info';
warnings.push({
level,
module: 'planner',
message: `Multiple formatters plan changes for ${path}: ${modules.join(', ')}. They will run in formatter order.`,
});
}
return warnings;
}
}
+43 -15
View File
@@ -1,5 +1,5 @@
import { BaseFormatter } from '../classes.baseformatter.js';
import type { IPlannedChange } from '../interfaces.format.js';
import type { IFormatWarning, IPlannedChange } from '../interfaces.format.js';
import * as plugins from '../mod.plugins.js';
import * as paths from '../../paths.js';
import { logger } from '../../gitzone.logging.js';
@@ -11,6 +11,10 @@ export class LicenseFormatter extends BaseFormatter {
return 'license';
}
get runsWithoutChanges(): boolean {
return true;
}
async analyze(): Promise<IPlannedChange[]> {
// License formatter only checks for incompatible licenses
// It does not modify any files, so return empty array
@@ -18,29 +22,34 @@ export class LicenseFormatter extends BaseFormatter {
return [];
}
async validate(): Promise<IFormatWarning[]> {
const result = await this.checkLicenses();
if (!result || result.failingModules.length === 0) {
return [];
}
return [
{
level: 'error',
module: this.name,
message: `License check failed for ${result.failingModules.length} module(s): ${result.failingModules
.map((failedModule) => `${failedModule.name} (${failedModule.license})`)
.join(', ')}`,
},
];
}
async execute(changes: IPlannedChange[]): Promise<void> {
const startTime = this.stats.moduleStartTime(this.name);
this.stats.startModule(this.name);
try {
// Check if node_modules exists
const nodeModulesPath = plugins.path.join(paths.cwd, 'node_modules');
const nodeModulesExists = await plugins.smartfs
.directory(nodeModulesPath)
.exists();
if (!nodeModulesExists) {
const licenseCheckResult = await this.checkLicenses();
if (!licenseCheckResult) {
logger.log('warn', 'No node_modules found. Skipping license check');
return;
}
// Run license check
const licenseChecker = await plugins.smartlegal.createLicenseChecker();
const licenseCheckResult = await licenseChecker.excludeLicenseWithinPath(
paths.cwd,
INCOMPATIBLE_LICENSES,
);
if (licenseCheckResult.failingModules.length === 0) {
logger.log('info', 'License check passed - no incompatible licenses found');
} else {
@@ -59,4 +68,23 @@ export class LicenseFormatter extends BaseFormatter {
async applyChange(change: IPlannedChange): Promise<void> {
// No file changes for license formatter
}
private async checkLicenses(): Promise<{
failingModules: Array<{ name: string; license: string }>;
} | undefined> {
const nodeModulesPath = plugins.path.join(paths.cwd, 'node_modules');
const nodeModulesExists = await plugins.smartfs
.directory(nodeModulesPath)
.exists();
if (!nodeModulesExists) {
return undefined;
}
const licenseChecker = await plugins.smartlegal.createLicenseChecker();
return await licenseChecker.excludeLicenseWithinPath(
paths.cwd,
INCOMPATIBLE_LICENSES,
);
}
}
+48 -60
View File
@@ -56,7 +56,8 @@ export class PrettierFormatter extends BaseFormatter {
);
allFiles.push(...filteredFiles);
} catch (error) {
logVerbose(`Skipping directory ${dir}: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
logVerbose(`Skipping directory ${dir}: ${errorMessage}`);
}
}
@@ -72,7 +73,8 @@ export class PrettierFormatter extends BaseFormatter {
const rootLevelFiles = rootFiles.filter((f) => !f.includes('/'));
allFiles.push(...rootLevelFiles);
} catch (error) {
logVerbose(`Skipping pattern ${pattern}: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
logVerbose(`Skipping pattern ${pattern}: ${errorMessage}`);
}
}
@@ -89,20 +91,46 @@ export class PrettierFormatter extends BaseFormatter {
}
} catch (error) {
// Skip files that can't be accessed
logVerbose(`Skipping ${file} - cannot access: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
logVerbose(`Skipping ${file} - cannot access: ${errorMessage}`);
}
}
const prettier = await import('prettier');
const prettierConfig = await this.getPrettierConfig();
for (const file of validFiles) {
try {
const fileExt = plugins.path.extname(file).toLowerCase();
if (!fileExt) {
continue;
}
const content = (await plugins.smartfs
.file(file)
.encoding('utf8')
.read()) as string;
const formatted = await prettier.format(content, {
filepath: file,
...prettierConfig,
});
if (formatted !== content) {
changes.push({
type: 'modify',
path: file,
module: this.name,
description: 'Format with Prettier',
content: formatted,
});
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logVerbose(`Skipping Prettier analysis for ${file}: ${errorMessage}`);
}
}
logger.log('info', `Found ${changes.length} files to format with Prettier`);
logger.log('info', `Found ${changes.length} files needing Prettier`);
return changes;
}
@@ -127,9 +155,10 @@ export class PrettierFormatter extends BaseFormatter {
this.stats.recordFileOperation(this.name, change.type, true);
} catch (error) {
this.stats.recordFileOperation(this.name, change.type, false);
const errorMessage = error instanceof Error ? error.message : String(error);
logger.log(
'error',
`Failed to format ${change.path}: ${error.message}`,
`Failed to format ${change.path}: ${errorMessage}`,
);
// Don't throw - continue with other files
}
@@ -192,28 +221,32 @@ export class PrettierFormatter extends BaseFormatter {
logVerbose(`No formatting changes for ${change.path}`);
}
} catch (prettierError) {
const prettierErrorMessage = prettierError instanceof Error
? prettierError.message
: String(prettierError);
// Check if it's a parser error
if (
prettierError.message &&
prettierError.message.includes('No parser could be inferred')
) {
logVerbose(`Skipping ${change.path} - ${prettierError.message}`);
if (prettierErrorMessage.includes('No parser could be inferred')) {
logVerbose(`Skipping ${change.path} - ${prettierErrorMessage}`);
return; // Skip this file silently
}
throw prettierError;
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
// Log the full error stack for debugging mkdir issues
if (error.message && error.message.includes('mkdir')) {
if (errorMessage.includes('mkdir')) {
logger.log(
'error',
`Failed to format ${change.path}: ${error.message}`,
`Failed to format ${change.path}: ${errorMessage}`,
);
logger.log('error', `Error stack: ${error.stack}`);
if (errorStack) {
logger.log('error', `Error stack: ${errorStack}`);
}
} else {
logger.log(
'error',
`Failed to format ${change.path}: ${error.message}`,
`Failed to format ${change.path}: ${errorMessage}`,
);
}
throw error;
@@ -234,52 +267,7 @@ export class PrettierFormatter extends BaseFormatter {
});
}
/**
* Override check() to compute diffs on-the-fly by running prettier
*/
async check(): Promise<ICheckResult> {
const changes = await this.analyze();
const diffs: ICheckResult['diffs'] = [];
for (const change of changes) {
if (change.type !== 'modify') continue;
try {
// Read current content
const currentContent = (await plugins.smartfs
.file(change.path)
.encoding('utf8')
.read()) as string;
// Skip files without extension (prettier can't infer parser)
const fileExt = plugins.path.extname(change.path).toLowerCase();
if (!fileExt) continue;
// Format with prettier to get what it would produce
const prettier = await import('prettier');
const formatted = await prettier.format(currentContent, {
filepath: change.path,
...(await this.getPrettierConfig()),
});
// Only add to diffs if content differs
if (formatted !== currentContent) {
diffs.push({
path: change.path,
type: 'modify',
before: currentContent,
after: formatted,
});
}
} catch (error) {
// Skip files that can't be processed
logVerbose(`Skipping diff for ${change.path}: ${error.message}`);
}
}
return {
hasDiff: diffs.length > 0,
diffs,
};
return await super.check();
}
}
+289 -8
View File
@@ -22,6 +22,7 @@ import { TsconfigFormatter } from "./formatters/tsconfig.formatter.js";
import { PrettierFormatter } from "./formatters/prettier.formatter.js";
import { ReadmeFormatter } from "./formatters/readme.formatter.js";
import { CopyFormatter } from "./formatters/copy.formatter.js";
import type { ICheckResult, IFormatPlan } from "./interfaces.format.js";
/**
* Rename npmextra.json or smartconfig.json to .smartconfig.json
@@ -94,9 +95,39 @@ const getFormatConfig = async () => {
};
};
const normalizeModuleList = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value.flatMap((item) => normalizeModuleList(item));
}
if (typeof value !== "string") {
return [];
}
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
};
const getPlanStatus = (plan: IFormatPlan) => {
const errorWarnings = plan.warnings.filter(
(warning) => warning.level === "error",
);
const hasChanges = plan.summary.totalFiles > 0;
const hasErrors = errorWarnings.length > 0;
return {
ok: !hasChanges && !hasErrors,
hasChanges,
hasErrors,
errorCount: errorWarnings.length,
};
};
const createActiveFormatters = async (options: {
interactive: boolean;
jsonOutput: boolean;
only?: string[];
skip?: string[];
}) => {
const project = await Project.fromCwd({ requireProjectType: false });
const context = new FormatContext(options);
@@ -107,11 +138,19 @@ const createActiveFormatters = async (options: {
([, FormatterClass]) => new FormatterClass(context, project),
);
const onlyModules = options.only?.length
? options.only
: formatConfig.modules.only;
const skipModules = [
...formatConfig.modules.skip,
...(options.skip || []),
];
const activeFormatters = formatters.filter((formatter) => {
if (formatConfig.modules.only.length > 0) {
return formatConfig.modules.only.includes(formatter.name);
if (onlyModules.length > 0) {
return onlyModules.includes(formatter.name);
}
if (formatConfig.modules.skip.includes(formatter.name)) {
if (skipModules.includes(formatter.name)) {
return false;
}
return true;
@@ -129,11 +168,15 @@ const buildFormatPlan = async (options: {
fromPlan?: string;
interactive: boolean;
jsonOutput: boolean;
only?: string[];
skip?: string[];
}) => {
const { context, planner, formatConfig, activeFormatters } =
await createActiveFormatters({
interactive: options.interactive,
jsonOutput: options.jsonOutput,
only: options.only,
skip: options.skip,
});
const plan = options.fromPlan
@@ -167,6 +210,182 @@ const serializePlan = (plan: any) => {
};
};
const buildFormatFixPrompt = (
plan: IFormatPlan,
extraInstructions: string,
): string => {
const promptParts = [
"Other /c-* commands can be found at ~/.config/opencode/commands/*",
"# gitzone format fix",
"",
`Working directory: ${process.cwd()}`,
"",
"Repair project formatting so `gitzone format check --json` passes.",
"",
"Rules:",
"- Read `.smartconfig.json`, `package.json`, `tsconfig.json`, and the current format plan before editing.",
"- Prefer deterministic gitzone standards, bundled assets, and existing project conventions.",
"- Keep changes focused on formatting, metadata normalization, templates, and config consistency.",
"- Do not commit, release, install dependencies, or modify unrelated files.",
"- Use pnpm commands only if commands are needed.",
"- Run `gitzone format --write --yes` after changes.",
"- Run `gitzone format check --json` after changes and keep fixing until it passes.",
"- Run `git diff --check` after changes to catch whitespace problems.",
"",
"Current format plan:",
JSON.stringify(serializePlan(plan), null, 2),
];
if (extraInstructions) {
promptParts.push("", "Additional user instructions:", extraInstructions);
}
return promptParts.join("\n");
};
const handleFormatFix = async (
options: Record<string, any>,
mode: ICliMode,
): Promise<void> => {
if (mode.json) {
printJson({
ok: false,
error:
"JSON output is not supported for `gitzone format fix`. Use `gitzone format check --json` for machine-readable diagnostics.",
});
process.exitCode = 1;
return;
}
const extraInstructions = (options._?.slice(2).join(" ") || "").trim();
const force = Boolean(options.force);
const autoApprove = Boolean(options.yes || mode.yes);
const formatConfig = await getFormatConfig();
const interactive =
options.interactive ?? (mode.interactive && formatConfig.interactive);
const only = normalizeModuleList(options.only);
const skip = normalizeModuleList(options.skip);
const buildCurrentPlan = async () => {
return await buildFormatPlan({
interactive,
jsonOutput: false,
only,
skip,
});
};
logger.log("info", "Analyzing project for format fixes...");
let { plan } = await buildCurrentPlan();
let status = getPlanStatus(plan);
if (status.ok && !extraInstructions && !force) {
logger.log(
"success",
"Format check found no issues. Use `gitzone format fix --force` to run opencode anyway.",
);
return;
}
if (!autoApprove) {
if (!mode.interactive) {
throw new Error(
"Format fix requires an interactive terminal or `-y` to run non-interactively.",
);
}
const confirmed = await plugins.smartinteract.SmartInteract.getCliConfirmation(
`Run format fixes? (${plan.summary.totalFiles} planned change(s), ${status.errorCount} error warning(s))`,
true,
);
if (!confirmed) {
logger.log("info", "Format fix cancelled.");
return;
}
}
if (status.hasChanges) {
logger.log("info", "Applying deterministic format changes first...");
await run({
_: ["format"],
write: true,
yes: true,
interactive: false,
verbose: options.verbose,
detailed: options.detailed,
only: options.only,
skip: options.skip,
});
({ plan } = await buildCurrentPlan());
status = getPlanStatus(plan);
if (status.ok && !extraInstructions && !force) {
logger.log("success", "Format fix completed successfully.");
return;
}
}
const opencodeArgs = [
"run",
"--title",
"gitzone format fix",
"--dir",
process.cwd(),
];
if (autoApprove) {
opencodeArgs.push("--dangerously-skip-permissions");
}
opencodeArgs.push(buildFormatFixPrompt(plan, extraInstructions));
logger.log("info", "Starting opencode format 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) {
logger.log("error", `opencode exited with code ${result.exitCode}`);
process.exitCode = result.exitCode || 1;
return;
}
logger.log("info", "Running deterministic format pass after opencode...");
await run({
_: ["format"],
write: true,
yes: true,
interactive: false,
verbose: options.verbose,
detailed: options.detailed,
only: options.only,
skip: options.skip,
});
const { planner: finalPlanner, plan: finalPlan } = await buildCurrentPlan();
await finalPlanner.displayPlan(finalPlan, options.detailed);
const finalStatus = getPlanStatus(finalPlan);
if (finalStatus.ok) {
logger.log("success", "Format fix completed successfully.");
return;
}
logger.log(
"error",
`Format fix left ${finalPlan.summary.totalFiles} planned change(s) and ${finalStatus.errorCount} error warning(s).`,
);
process.exitCode = 1;
};
export let run = async (
options: {
write?: boolean;
@@ -194,8 +413,25 @@ export let run = async (
setVerboseMode(true);
}
if (subcommand === "fix") {
await handleFormatFix(options, mode);
return;
}
const shouldWrite = options.write ?? options.dryRun === false;
const treatAsPlan = subcommand === "plan";
const treatAsCheck = subcommand === "check" || Boolean(options.check);
if (treatAsCheck && shouldWrite) {
const error = "`gitzone format check` is read-only and cannot be combined with --write.";
if (mode.json) {
printJson({ ok: false, error });
} else {
logger.log("error", error);
}
process.exitCode = 1;
return;
}
if (mode.json && shouldWrite) {
printJson({
@@ -212,7 +448,9 @@ export let run = async (
const formatConfig = await getFormatConfig();
const interactive =
options.interactive ?? (mode.interactive && formatConfig.interactive);
const autoApprove = options.yes ?? formatConfig.autoApprove;
const autoApprove = options.yes ?? (mode.yes || formatConfig.autoApprove);
const only = normalizeModuleList(options.only);
const skip = normalizeModuleList(options.skip);
try {
const planBuilder = async () => {
@@ -220,6 +458,8 @@ export let run = async (
fromPlan: options.fromPlan,
interactive,
jsonOutput: mode.json,
only,
skip,
});
};
@@ -231,7 +471,16 @@ export let run = async (
: await planBuilder();
if (mode.json) {
printJson(serializePlan(plan));
const serializedPlan = serializePlan(plan);
if (treatAsCheck) {
const status = getPlanStatus(plan);
printJson({ ok: status.ok, ...serializedPlan });
if (!status.ok) {
process.exitCode = 1;
}
return;
}
printJson(serializedPlan);
return;
}
@@ -251,6 +500,20 @@ export let run = async (
return;
}
if (treatAsCheck) {
const status = getPlanStatus(plan);
if (status.ok) {
logger.log("success", "Format check passed");
} else {
logger.log(
"error",
`Format check failed: ${plan.summary.totalFiles} planned change(s), ${status.errorCount} error warning(s)`,
);
process.exitCode = 1;
}
return;
}
// Show diffs if explicitly requested or before interactive write confirmation
const showDiffs =
options.diff || (shouldWrite && interactive && !autoApprove);
@@ -314,7 +577,6 @@ export let run = async (
}
};
import type { ICheckResult } from "./interfaces.format.js";
export type { ICheckResult };
/**
@@ -363,7 +625,7 @@ export function showHelp(mode?: ICliMode): void {
if (mode?.json) {
printJson({
command: "format",
usage: "gitzone format [plan] [options]",
usage: "gitzone format [plan|check|fix] [options]",
description:
"Plans formatting changes by default and applies them only with --write.",
flags: [
@@ -393,19 +655,33 @@ export function showHelp(mode?: ICliMode): void {
flag: "--diff",
description: "Show per-file diffs before applying changes",
},
{
flag: "--only <modules>",
description: "Run only the comma-separated formatter modules",
},
{
flag: "--skip <modules>",
description: "Skip the comma-separated formatter modules",
},
{
flag: "--force",
description: "Run `format fix` even when the deterministic plan is clean",
},
{ flag: "--json", description: "Emit a read-only format plan as JSON" },
],
examples: [
"gitzone format",
"gitzone format plan --json",
"gitzone format check",
"gitzone format --write --yes",
"gitzone format fix",
],
});
return;
}
console.log("");
console.log("Usage: gitzone format [plan] [options]");
console.log("Usage: gitzone format [plan|check|fix] [options]");
console.log("");
console.log(
"Plans formatting changes by default and applies them only with --write.",
@@ -424,11 +700,16 @@ export function showHelp(mode?: ICliMode): void {
console.log(
" --diff Show per-file diffs before applying changes",
);
console.log(" --only <modules> Run only comma-separated formatter modules");
console.log(" --skip <modules> Skip comma-separated formatter modules");
console.log(" --force Run format fix even when the plan is clean");
console.log(" --json Emit a read-only format plan as JSON");
console.log("");
console.log("Examples:");
console.log(" gitzone format");
console.log(" gitzone format plan --json");
console.log(" gitzone format check");
console.log(" gitzone format --write --yes");
console.log(" gitzone format fix");
console.log("");
}
+8 -11
View File
@@ -1,3 +1,9 @@
export type IFormatWarning = {
level: 'info' | 'warning' | 'error';
message: string;
module: string;
};
export type IFormatPlan = {
summary: {
totalFiles: number;
@@ -5,17 +11,8 @@ export type IFormatPlan = {
filesModified: number;
filesRemoved: number;
};
changes: Array<{
type: 'create' | 'modify' | 'delete';
path: string;
module: string;
description: string;
}>;
warnings: Array<{
level: 'info' | 'warning' | 'error';
message: string;
module: string;
}>;
changes: IPlannedChange[];
warnings: IFormatWarning[];
};
export type IPlannedChange = {
+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");
+1
View File
@@ -202,6 +202,7 @@ export async function showHelp(
console.log(" gitzone commit recommend --json");
console.log(" gitzone release --plan");
console.log(" gitzone format plan --json");
console.log(" gitzone format check");
console.log(" gitzone services set mongodb,minio");
console.log(" gitzone tools update");
console.log("");