Compare commits

...

7 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
20 changed files with 1121 additions and 339 deletions
+2 -1
View File
@@ -63,7 +63,8 @@
}, },
"docker": { "docker": {
"enabled": false, "enabled": false,
"images": [] "engine": "tsdocker",
"patterns": []
} }
} }
} }
+25
View File
@@ -3,6 +3,31 @@
## Pending ## 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 ## 2026-05-10 - 2.18.0
### Features ### Features
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "@git.zone/cli", "name": "@git.zone/cli",
"private": false, "private": false,
"version": "2.18.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.", "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", "main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts", "typings": "dist_ts/index.d.ts",
@@ -87,7 +87,7 @@
"@push.rocks/smartpath": "^6.0.0", "@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartscaf": "^4.0.21", "@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/smartunique": "^3.0.9",
"@push.rocks/smartupdate": "^2.0.6", "@push.rocks/smartupdate": "^2.0.6",
"prettier": "^3.8.1" "prettier": "^3.8.1"
+35 -23
View File
@@ -81,8 +81,8 @@ importers:
specifier: ^4.0.21 specifier: ^4.0.21
version: 4.0.21 version: 4.0.21
'@push.rocks/smartshell': '@push.rocks/smartshell':
specifier: ^3.3.7 specifier: ^3.5.0
version: 3.3.7 version: 3.5.0
'@push.rocks/smartunique': '@push.rocks/smartunique':
specifier: ^3.0.9 specifier: ^3.0.9
version: 3.0.9 version: 3.0.9
@@ -1063,6 +1063,9 @@ packages:
'@push.rocks/smartdelay@3.0.5': '@push.rocks/smartdelay@3.0.5':
resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==} 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': '@push.rocks/smartdiff@1.1.0':
resolution: {integrity: sha512-AAz/unmko0C+g+60odOoK32PE3Ci3YLoB+zfg1LGLyVRCthcdzjqa1C2Km0MfG7IyJQKPdj8J5HPubtpm3ZeaQ==} resolution: {integrity: sha512-AAz/unmko0C+g+60odOoK32PE3Ci3YLoB+zfg1LGLyVRCthcdzjqa1C2Km0MfG7IyJQKPdj8J5HPubtpm3ZeaQ==}
@@ -1192,6 +1195,9 @@ packages:
'@push.rocks/smartpromise@4.2.3': '@push.rocks/smartpromise@4.2.3':
resolution: {integrity: sha512-Ycg/TJR+tMt+S3wSFurOpEoW6nXv12QBtKXgBcjMZ4RsdO28geN46U09osPn9N9WuwQy1PkmTV5J/V4F9U8qEw==} 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': '@push.rocks/smartpuppeteer@2.0.5':
resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==} resolution: {integrity: sha512-yK/qSeWVHIGWRp3c8S5tfdGP6WCKllZC4DR8d8CQlEjszOSBmHtlTdyyqOMBZ/BA4kd+eU5f3A1r4K2tGYty1g==}
@@ -1222,8 +1228,8 @@ packages:
'@push.rocks/smartserve@2.0.1': '@push.rocks/smartserve@2.0.1':
resolution: {integrity: sha512-YQb2qexfCzCqOlLWBBXKMg6xG4zahCPAxomz/KEKAwHtW6wMTtuHKSTSkRTQ0vl9jssLMAmRz2OyafiL9XGJXQ==} resolution: {integrity: sha512-YQb2qexfCzCqOlLWBBXKMg6xG4zahCPAxomz/KEKAwHtW6wMTtuHKSTSkRTQ0vl9jssLMAmRz2OyafiL9XGJXQ==}
'@push.rocks/smartshell@3.3.7': '@push.rocks/smartshell@3.5.0':
resolution: {integrity: sha512-b3st2+FjHUVhZZRlXfw93+SQA0UMVlURqe55uVpWdjJX7jeGXTTeszuYygtiR99zC5iZ8WZhGDct3N2L1qc/qw==} resolution: {integrity: sha512-Hx9TVvC/AWxZsnm1GDb+W4Fe58nf1FkKbSBABUgkxct4XRYugBI2z9Twnjm3R9vdRry8oy0enfR9NPVhisGaGA==}
'@push.rocks/smartspawn@3.0.3': '@push.rocks/smartspawn@3.0.3':
resolution: {integrity: sha512-DyrGPV69wwOiJgKkyruk5hS3UEGZ99xFAqBE9O2nM8VXCRLbbty3xt1Ug5Z092ZZmJYaaGMSnMw3ijyZJFCT0Q==} resolution: {integrity: sha512-DyrGPV69wwOiJgKkyruk5hS3UEGZ99xFAqBE9O2nM8VXCRLbbty3xt1Ug5Z092ZZmJYaaGMSnMw3ijyZJFCT0Q==}
@@ -3917,9 +3923,9 @@ packages:
engines: {node: '>= 8'} engines: {node: '>= 8'}
hasBin: true hasBin: true
which@6.0.1: which@7.0.0:
resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} resolution: {integrity: sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==}
engines: {node: ^20.17.0 || >=22.9.0} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
hasBin: true hasBin: true
wordwrap@1.0.0: wordwrap@1.0.0:
@@ -4802,7 +4808,7 @@ snapshots:
'@push.rocks/smartlog': 3.2.1 '@push.rocks/smartlog': 3.2.1
'@push.rocks/smartlog-destination-local': 9.0.2 '@push.rocks/smartlog-destination-local': 9.0.2
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
'@push.rocks/smartshell': 3.3.7 '@push.rocks/smartshell': 3.5.0
'@push.rocks/smarttime': 4.2.3 '@push.rocks/smarttime': 4.2.3
typedoc: 0.28.17(typescript@5.9.3) typedoc: 0.28.17(typescript@5.9.3)
typescript: 5.9.3 typescript: 5.9.3
@@ -4832,7 +4838,7 @@ snapshots:
'@push.rocks/smartnpm': 2.0.6 '@push.rocks/smartnpm': 2.0.6
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
'@push.rocks/smartrequest': 5.0.1 '@push.rocks/smartrequest': 5.0.1
'@push.rocks/smartshell': 3.3.7 '@push.rocks/smartshell': 3.5.0
transitivePeerDependencies: transitivePeerDependencies:
- '@nuxt/kit' - '@nuxt/kit'
- aws-crt - aws-crt
@@ -4845,7 +4851,7 @@ snapshots:
'@git.zone/tsrun@2.0.1': '@git.zone/tsrun@2.0.1':
dependencies: dependencies:
'@push.rocks/smartfile': 13.1.2 '@push.rocks/smartfile': 13.1.2
'@push.rocks/smartshell': 3.3.7 '@push.rocks/smartshell': 3.5.0
tsx: 4.21.0 tsx: 4.21.0
'@git.zone/tstest@3.3.2(socks@2.8.7)(typescript@5.9.3)': '@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/smartrequest': 5.0.1
'@push.rocks/smarts3': 5.3.0 '@push.rocks/smarts3': 5.3.0
'@push.rocks/smartserve': 2.0.1 '@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/smarttime': 4.2.3
'@push.rocks/smartwatch': 6.3.0 '@push.rocks/smartwatch': 6.3.0
'@types/ws': 8.18.1 '@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/smartai': 2.0.0(typescript@5.9.3)(ws@8.19.0)(zod@3.25.76)
'@push.rocks/smartfs': 1.5.0 '@push.rocks/smartfs': 1.5.0
'@push.rocks/smartrequest': 5.0.1 '@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) ai: 6.0.116(zod@3.25.76)
zod: 3.25.76 zod: 3.25.76
transitivePeerDependencies: transitivePeerDependencies:
@@ -5775,6 +5781,10 @@ snapshots:
dependencies: dependencies:
'@push.rocks/smartpromise': 4.2.3 '@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': '@push.rocks/smartdiff@1.1.0':
dependencies: dependencies:
diff: 8.0.3 diff: 8.0.3
@@ -5818,7 +5828,7 @@ snapshots:
'@push.rocks/smartexit@2.0.3': '@push.rocks/smartexit@2.0.3':
dependencies: dependencies:
'@push.rocks/lik': 6.3.1 '@push.rocks/lik': 6.3.1
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.4
'@push.rocks/smartexpect@2.5.0': '@push.rocks/smartexpect@2.5.0':
dependencies: dependencies:
@@ -5897,7 +5907,7 @@ snapshots:
'@push.rocks/smartfile': 11.2.7 '@push.rocks/smartfile': 11.2.7
'@push.rocks/smartpath': 6.0.0 '@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.3 '@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/smartstring': 4.1.0
'@push.rocks/smarttime': 4.2.3 '@push.rocks/smarttime': 4.2.3
'@types/diff': 8.0.0 '@types/diff': 8.0.0
@@ -5968,7 +5978,7 @@ snapshots:
'@push.rocks/smartmustache': 3.0.2 '@push.rocks/smartmustache': 3.0.2
'@push.rocks/smartpnpm': 1.0.6 '@push.rocks/smartpnpm': 1.0.6
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.3
'@push.rocks/smartshell': 3.3.7 '@push.rocks/smartshell': 3.5.0
'@tsclass/tsclass': 4.4.4 '@tsclass/tsclass': 4.4.4
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -6159,14 +6169,16 @@ snapshots:
'@push.rocks/smartpnpm@1.0.6': '@push.rocks/smartpnpm@1.0.6':
dependencies: dependencies:
'@push.rocks/smartshell': 3.3.7 '@push.rocks/smartshell': 3.5.0
'@push.rocks/smartpromise@4.2.3': {} '@push.rocks/smartpromise@4.2.3': {}
'@push.rocks/smartpromise@4.2.4': {}
'@push.rocks/smartpuppeteer@2.0.5(typescript@5.9.3)': '@push.rocks/smartpuppeteer@2.0.5(typescript@5.9.3)':
dependencies: dependencies:
'@push.rocks/smartdelay': 3.0.5 '@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) puppeteer: 24.35.0(typescript@5.9.3)
tree-kill: 1.2.2 tree-kill: 1.2.2
transitivePeerDependencies: transitivePeerDependencies:
@@ -6233,7 +6245,7 @@ snapshots:
'@push.rocks/smartinteract': 2.0.16 '@push.rocks/smartinteract': 2.0.16
'@push.rocks/smartobject': 1.0.12 '@push.rocks/smartobject': 1.0.12
'@push.rocks/smartpromise': 4.2.3 '@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/smartyaml': 3.0.4
'@push.rocks/smartserve@2.0.1': '@push.rocks/smartserve@2.0.1':
@@ -6249,13 +6261,13 @@ snapshots:
- bufferutil - bufferutil
- utf-8-validate - utf-8-validate
'@push.rocks/smartshell@3.3.7': '@push.rocks/smartshell@3.5.0':
dependencies: dependencies:
'@push.rocks/smartdelay': 3.0.5 '@push.rocks/smartdelay': 3.1.0
'@push.rocks/smartexit': 2.0.3 '@push.rocks/smartexit': 2.0.3
'@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartpromise': 4.2.4
'@types/which': 3.0.4 '@types/which': 3.0.4
which: 6.0.1 which: 7.0.0
'@push.rocks/smartspawn@3.0.3': '@push.rocks/smartspawn@3.0.3':
dependencies: dependencies:
@@ -9513,7 +9525,7 @@ snapshots:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
which@6.0.1: which@7.0.0:
dependencies: dependencies:
isexe: 4.0.0 isexe: 4.0.0
+10
View File
@@ -193,12 +193,22 @@ gitzone format
# Read-only JSON plan # Read-only JSON plan
gitzone format plan --json gitzone format plan --json
# CI-friendly check, exits non-zero when changes or validator errors remain
gitzone format check
# Dry run to preview changes # Dry run to preview changes
gitzone format --dry-run gitzone format --dry-run
# Limit formatter modules
gitzone format --only prettier,packagejson
gitzone format --skip license
# Non-interactive apply # Non-interactive apply
gitzone format --write --yes gitzone format --write --yes
# Deterministic format first, opencode for remaining issues
gitzone format fix
# Plan only (no execution) # Plan only (no execution)
gitzone format --plan-only gitzone format --plan-only
+31 -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 | | `git` | Pushes the release commit and tags, often triggering remote CI release builds |
| `npm` | Publishes the package to configured npm registries | | `npm` | Publishes the package to configured npm registries |
| `docker` | Builds and pushes configured Docker images | | `docker` | Delegates container builds and pushes to `tsdocker` |
```bash ```bash
# Preview the resolved release plan # Preview the resolved release plan
@@ -205,7 +205,7 @@ The standard buckets are `Breaking Changes`, `Features`, `Fixes`, `Documentation
## Configuration ## 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 ```json
{ {
@@ -237,11 +237,21 @@ All CLI config lives under `@git.zone/cli` in `.smartconfig.json`.
"alreadyPublished": "success" "alreadyPublished": "success"
}, },
"docker": { "docker": {
"enabled": false, "enabled": true,
"images": [] "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 @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: Useful config commands:
```bash ```bash
@@ -293,15 +309,26 @@ gitzone format
# Emit a machine-readable plan # Emit a machine-readable plan
gitzone format plan --json 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 # Apply changes
gitzone format --write gitzone format --write
# Apply without prompt # Apply without prompt
gitzone format --write --yes 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. 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 ## Development Services
`gitzone services` manages local Docker-backed services for development projects. `gitzone services` manages local Docker-backed services for development projects.
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@git.zone/cli', name: '@git.zone/cli',
version: '2.18.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.' 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.'
} }
+104 -144
View File
@@ -1,13 +1,107 @@
import * as plugins from "./plugins.js"; import * as plugins from "./plugins.js";
import * as paths from "./paths.js"; import * as paths from "./paths.js";
import { GitzoneConfig } from "./classes.gitzoneconfig.js"; import {
import { getRawCliMode } from "./helpers.climode.js"; getProcessUserArgv,
getRawCliMode,
parseCliArgv,
} from "./helpers.climode.js";
import { commitinfo } from "./00_commitinfo_data.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];
switch (command) {
case undefined:
case "help": {
const modStandard = await import("./mod_standard/index.js");
await modStandard.run(argvArg);
break;
}
case "commit": {
const modCommit = await import("./mod_commit/index.js");
await modCommit.run(argvArg);
break;
}
case "release": {
const modRelease = await import("./mod_release/index.js");
await modRelease.run(argvArg);
break;
}
case "deprecate": {
const modDeprecate = await import("./mod_deprecate/index.js");
await modDeprecate.run();
break;
}
case "docker": {
const modDocker = await import("./mod_docker/index.js");
await modDocker.run(argvArg);
break;
}
case "format": {
const modFormat = await import("./mod_format/index.js");
await modFormat.run({
...argvArg,
write: argvArg.write || argvArg.w,
dryRun: argvArg["dry-run"],
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,
});
break;
}
case "meta": {
const modMeta = await import("./mod_meta/index.js");
await modMeta.run(argvArg);
break;
}
case "open": {
const modOpen = await import("./mod_open/index.js");
await modOpen.run(argvArg);
break;
}
case "template": {
const modTemplate = await import("./mod_template/index.js");
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");
await modHelpers.run(argvArg);
break;
}
case "tools": {
const modTools = await import("./mod_tools/index.js");
await modTools.run(argvArg);
break;
}
case "config": {
const modConfig = await import("./mod_config/index.js");
await modConfig.run(argvArg);
break;
}
case "services": {
const modServices = await import("./mod_services/index.js");
await modServices.run(argvArg);
break;
}
default: {
const modStandard = await import("./mod_standard/index.js");
await modStandard.run(argvArg);
}
}
};
export let run = async () => { export let run = async () => {
const done = plugins.smartpromise.defer();
const rawCliMode = await getRawCliMode(); const rawCliMode = await getRawCliMode();
// get packageInfo // get packageInfo
@@ -34,144 +128,10 @@ export let run = async () => {
if (rawCliMode.output === "human") { if (rawCliMode.output === "human") {
console.log("---------------------------------------------"); console.log("---------------------------------------------");
} }
gitzoneSmartcli.addVersion(packageVersion); const argvArg = parseCliArgv(getProcessUserArgv());
if (argvArg.v || argvArg.version) {
// ======> Standard task <====== console.log(packageVersion);
return;
/** }
* standard task await runParsedCommand(argvArg);
*/
gitzoneSmartcli.standardCommand().subscribe(async (argvArg) => {
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) => {
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) => {
const modRelease = await import("./mod_release/index.js");
await modRelease.run(argvArg);
});
/**
* deprecate a package on npm
*/
gitzoneSmartcli.addCommand("deprecate").subscribe(async (argvArg) => {
const modDeprecate = await import("./mod_deprecate/index.js");
await modDeprecate.run();
});
/**
* docker
*/
gitzoneSmartcli.addCommand("docker").subscribe(async (argvArg) => {
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();
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"],
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();
const modMeta = await import("./mod_meta/index.js");
modMeta.run(argvArg);
});
/**
* open assets
*/
gitzoneSmartcli.addCommand("open").subscribe(async (argvArg) => {
const modOpen = await import("./mod_open/index.js");
modOpen.run(argvArg);
});
/**
* add a readme to a project
*/
gitzoneSmartcli.addCommand("template").subscribe(async (argvArg) => {
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) => {
const modHelpers = await import("./mod_helpers/index.js");
modHelpers.run(argvArg);
});
/**
* manage the global @git.zone toolchain
*/
gitzoneSmartcli.addCommand("tools").subscribe(async (argvArg) => {
const modTools = await import("./mod_tools/index.js");
await modTools.run(argvArg);
});
/**
* manage release configuration
*/
gitzoneSmartcli.addCommand("config").subscribe(async (argvArg) => {
const modConfig = await import("./mod_config/index.js");
await modConfig.run(argvArg);
});
/**
* manage development services (MongoDB, S3/MinIO)
*/
gitzoneSmartcli.addCommand("services").subscribe(async (argvArg) => {
const modServices = await import("./mod_services/index.js");
await modServices.run(argvArg);
});
// start parsing of the cli
gitzoneSmartcli.startParse();
return await done.promise;
}; };
+36 -1
View File
@@ -88,6 +88,41 @@ const parseRawArgv = (argv: string[]): TArgSource => {
return parsedArgv; 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 => { const normalizeOutputMode = (value: unknown): TCliOutputMode | undefined => {
if (value === "human" || value === "plain" || value === "json") { if (value === "human" || value === "plain" || value === "json") {
return value; return value;
@@ -171,7 +206,7 @@ export const getCliMode = async (
export const getRawCliMode = async (): Promise<ICliMode> => { export const getRawCliMode = async (): Promise<ICliMode> => {
const cliConfig = await getCliModeConfig(); const cliConfig = await getCliModeConfig();
const rawArgv = parseRawArgv(process.argv.slice(2)); const rawArgv = parseRawArgv(getProcessUserArgv());
return resolveCliMode(rawArgv, cliConfig); 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]; return parent[key];
}; };
const normalizeRegistryList = (registries: unknown[]): string[] => {
const result: string[] = [];
for (const registry of registries) {
if (typeof registry !== "string" || !registry.trim()) {
continue;
}
const normalizedRegistry = normalizeRegistryUrl(registry);
if (!result.includes(normalizedRegistry)) {
result.push(normalizedRegistry);
}
}
return result;
};
const migrateLegacyReleaseArray = (smartconfigJson: Record<string, any>): boolean => {
const cliConfig = ensureObject(smartconfigJson, CLI_NAMESPACE);
if (!Array.isArray(cliConfig.release)) {
return false;
}
const registries = normalizeRegistryList(cliConfig.release);
cliConfig.release = {
targets: {
npm: {
enabled: registries.length > 0,
registries,
},
},
};
return true;
};
const migrateNamespaceKeys = (smartconfigJson: Record<string, any>): boolean => { const migrateNamespaceKeys = (smartconfigJson: Record<string, any>): boolean => {
let migrated = false; let migrated = false;
const migrations = [ const migrations = [
@@ -50,9 +82,9 @@ const migrateNamespaceKeys = (smartconfigJson: Record<string, any>): boolean =>
const migrateToV2 = (smartconfigJson: Record<string, any>): boolean => { const migrateToV2 = (smartconfigJson: Record<string, any>): boolean => {
const cliConfig = ensureObject(smartconfigJson, CLI_NAMESPACE); const cliConfig = ensureObject(smartconfigJson, CLI_NAMESPACE);
let migrated = migrateLegacyReleaseArray(smartconfigJson);
const releaseConfig = ensureObject(cliConfig, "release"); const releaseConfig = ensureObject(cliConfig, "release");
let migrated = false;
const targets = ensureObject(releaseConfig, "targets"); const targets = ensureObject(releaseConfig, "targets");
const shipzoneConfig = smartconfigJson["@ship.zone/szci"]; const shipzoneConfig = smartconfigJson["@ship.zone/szci"];
@@ -68,8 +100,10 @@ const migrateToV2 = (smartconfigJson: Record<string, any>): boolean => {
migrated = true; migrated = true;
} }
if (isPlainObject(releaseConfig.docker) && !isPlainObject(targets.docker)) { if (isPlainObject(releaseConfig.docker)) {
targets.docker = releaseConfig.docker; targets.docker = isPlainObject(targets.docker)
? { ...releaseConfig.docker, ...targets.docker }
: releaseConfig.docker;
delete releaseConfig.docker; delete releaseConfig.docker;
migrated = true; migrated = true;
} }
@@ -141,11 +175,27 @@ const migrateToV2 = (smartconfigJson: Record<string, any>): boolean => {
if (dockerTarget.enabled === undefined) { if (dockerTarget.enabled === undefined) {
dockerTarget.enabled = true; dockerTarget.enabled = true;
} }
dockerTarget.engine = "tsdocker";
} }
delete releaseConfig.steps; delete releaseConfig.steps;
migrated = true; 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) { if (releaseConfig.changelog) {
delete releaseConfig.changelog; delete releaseConfig.changelog;
migrated = true; migrated = true;
@@ -174,6 +224,10 @@ export const migrateSmartconfigData = (
const fromVersion = typeof cliConfig.schemaVersion === "number" ? cliConfig.schemaVersion : 1; const fromVersion = typeof cliConfig.schemaVersion === "number" ? cliConfig.schemaVersion : 1;
let currentVersion = fromVersion; let currentVersion = fromVersion;
if (targetVersion >= 2) {
migrated = migrateLegacyReleaseArray(smartconfigJson) || migrated;
}
if (currentVersion < 2 && targetVersion >= 2) { if (currentVersion < 2 && targetVersion >= 2) {
migrated = migrateToV2(smartconfigJson) || migrated; migrated = migrateToV2(smartconfigJson) || migrated;
currentVersion = 2; currentVersion = 2;
+20 -3
View File
@@ -52,7 +52,12 @@ export interface IReleaseNpmTargetConfig {
export interface IReleaseDockerTargetConfig { export interface IReleaseDockerTargetConfig {
enabled?: boolean; enabled?: boolean;
images?: string[]; engine?: "tsdocker";
patterns?: string[];
cached?: boolean;
parallel?: boolean | number;
context?: string;
noBuild?: boolean;
} }
export interface IReleaseWorkflowConfig { export interface IReleaseWorkflowConfig {
@@ -109,7 +114,12 @@ export interface IResolvedReleaseWorkflow {
npmAccessLevel: "public" | "private"; npmAccessLevel: "public" | "private";
npmAlreadyPublished: "success" | "error"; npmAlreadyPublished: "success" | "error";
dockerEnabled: boolean; dockerEnabled: boolean;
dockerImages: string[]; dockerEngine: "tsdocker";
dockerPatterns: string[];
dockerCached: boolean;
dockerParallel: boolean | number;
dockerContext?: string;
dockerNoBuild: boolean;
} }
interface ICliWorkflowConfig { interface ICliWorkflowConfig {
@@ -382,6 +392,13 @@ export const resolveReleaseWorkflow = async (argvArg: any): Promise<IResolvedRel
npmAccessLevel: npmConfig.accessLevel || "public", npmAccessLevel: npmConfig.accessLevel || "public",
npmAlreadyPublished: npmConfig.alreadyPublished || "success", npmAlreadyPublished: npmConfig.alreadyPublished || "success",
dockerEnabled, 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,
}; };
}; };
+278 -29
View File
@@ -168,7 +168,7 @@ async function handleInteractiveMenu(): Promise<void> {
{ name: "Configure release workflow", value: "release" }, { name: "Configure release workflow", value: "release" },
{ name: "Configure services", value: "services" }, { name: "Configure services", value: "services" },
{ name: "Validate configuration (doctor)", value: "doctor" }, { name: "Validate configuration (doctor)", value: "doctor" },
{ name: "Fix configuration with opencode", value: "fix" }, { name: "Fix configuration", value: "fix" },
{ name: "Add an npm target registry", value: "add" }, { name: "Add an npm target registry", value: "add" },
{ name: "Remove an npm target registry", value: "remove" }, { name: "Remove an npm target registry", value: "remove" },
{ name: "Clear npm target registries", value: "clear" }, { name: "Clear npm target registries", value: "clear" },
@@ -793,7 +793,7 @@ async function handleRelease(mode: ICliMode): Promise<void> {
choices: [ choices: [
{ name: "git - push branch and tags", value: "git" }, { name: "git - push branch and tags", value: "git" },
{ name: "npm - publish package registries", value: "npm" }, { 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), default: getDefaultEnabledTargets(currentTargets),
}); });
@@ -860,21 +860,49 @@ async function handleRelease(mode: ICliMode): Promise<void> {
} }
if (enabledTargets.includes("docker")) { if (enabledTargets.includes("docker")) {
const images = await askValue<string>(interactInstance, { const patterns = await askValue<string>(interactInstance, {
type: "input", type: "input",
name: "dockerImages", name: "dockerPatterns",
message: "Docker image templates (comma-separated, supports {{version}}):", message: "tsdocker Dockerfile patterns (comma-separated, empty means all):",
default: Array.isArray(currentTargets.docker?.images) default: Array.isArray(currentTargets.docker?.patterns)
? currentTargets.docker.images.join(", ") ? 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 = { releaseTargets.docker = {
...(currentTargets.docker || {}),
enabled: true, enabled: true,
images: parseCsv(images), engine: "tsdocker",
patterns: parseCsv(patterns),
cached,
parallel: parseDockerParallel(parallel),
context: context.trim() || undefined,
noBuild,
}; };
} else { } else {
releaseTargets.docker = { ...(currentTargets.docker || {}), enabled: false }; releaseTargets.docker = { enabled: false, engine: "tsdocker" };
} }
setCliConfigValueInData(smartconfigData, "schemaVersion", CURRENT_GITZONE_CLI_SCHEMA_VERSION); setCliConfigValueInData(smartconfigData, "schemaVersion", CURRENT_GITZONE_CLI_SCHEMA_VERSION);
@@ -911,8 +939,8 @@ async function handleFix(argvArg: any, mode: ICliMode): Promise<void> {
return; return;
} }
const findings = await collectDoctorFindings(); let findings = await collectDoctorFindings();
const counts = countDoctorFindings(findings); let counts = countDoctorFindings(findings);
const extraInstructions = (argvArg._?.slice(2).join(" ") || "").trim(); const extraInstructions = (argvArg._?.slice(2).join(" ") || "").trim();
const force = Boolean(argvArg.force); const force = Boolean(argvArg.force);
@@ -926,10 +954,10 @@ async function handleFix(argvArg: any, mode: ICliMode): Promise<void> {
if (!mode.yes) { if (!mode.yes) {
if (!mode.interactive) { if (!mode.interactive) {
throw new Error("Config fix requires an interactive terminal or `-y` to run opencode non-interactively."); throw new Error("Config fix requires an interactive terminal or `-y` to run non-interactively.");
} }
const confirmed = await plugins.smartinteract.SmartInteract.getCliConfirmation( const confirmed = await plugins.smartinteract.SmartInteract.getCliConfirmation(
`Run opencode to fix .smartconfig.json? (${counts.error} error, ${counts.warn} warning)`, `Run configuration fixes for .smartconfig.json? (${counts.error} error, ${counts.warn} warning)`,
true, true,
); );
if (!confirmed) { if (!confirmed) {
@@ -938,6 +966,16 @@ async function handleFix(argvArg: any, mode: ICliMode): Promise<void> {
} }
} }
const appliedKnownFixes = await applyKnownConfigFixes(mode);
if (appliedKnownFixes) {
findings = await collectDoctorFindings();
counts = countDoctorFindings(findings);
if (counts.error === 0 && counts.warn === 0 && !extraInstructions && !force) {
printDoctorResult(findings, mode);
return;
}
}
const opencodeArgs = [ const opencodeArgs = [
"run", "run",
"--title", "--title",
@@ -959,7 +997,7 @@ async function handleFix(argvArg: any, mode: ICliMode): Promise<void> {
let result: plugins.smartshell.IExecResult; let result: plugins.smartshell.IExecResult;
try { try {
result = await smartshellInstance.execSpawn("opencode", opencodeArgs, { result = await smartshellInstance.execSpawn("opencode", opencodeArgs, {
passthrough: true, stdio: "inherit",
}); });
} catch (error) { } catch (error) {
throw new Error(`Failed to run opencode: ${error instanceof Error ? error.message : String(error)}`); throw new Error(`Failed to run opencode: ${error instanceof Error ? error.message : String(error)}`);
@@ -976,6 +1014,33 @@ async function handleFix(argvArg: any, mode: ICliMode): Promise<void> {
printDoctorResult(finalFindings, mode); printDoctorResult(finalFindings, mode);
} }
async function applyKnownConfigFixes(mode: ICliMode): Promise<boolean> {
const smartconfigPath = getSmartconfigPath();
if (!(await plugins.smartfs.file(smartconfigPath).exists())) {
return false;
}
let smartconfigData: Record<string, any>;
try {
smartconfigData = await readSmartconfigFile();
} catch {
return false;
}
const result = migrateSmartconfigData(smartconfigData);
if (!result.migrated) {
return false;
}
await writeSmartconfigFile(smartconfigData);
plugins.logger.log(
"success",
`Applied known .smartconfig.json migrations to schema v${result.toVersion}`,
);
await formatSmartconfigWithDiff(mode);
return true;
}
async function collectDoctorFindings(): Promise<IDoctorFinding[]> { async function collectDoctorFindings(): Promise<IDoctorFinding[]> {
const findings: IDoctorFinding[] = []; const findings: IDoctorFinding[] = [];
const smartconfigPath = getSmartconfigPath(); const smartconfigPath = getSmartconfigPath();
@@ -1043,7 +1108,7 @@ async function collectDoctorFindings(): Promise<IDoctorFinding[]> {
await validateDetectedProjectType(cliConfig, findings); await validateDetectedProjectType(cliConfig, findings);
validateCommitConfig(cliConfig.commit || {}, findings); validateCommitConfig(cliConfig.commit || {}, findings);
await validateReleaseConfig(cliConfig.release || {}, findings); await validateReleaseConfig(cliConfig.release, smartconfigData, findings);
return findings; return findings;
} }
@@ -1291,9 +1356,14 @@ function formatTarget(enabled: unknown, targetConfig: any): string {
details.push(`registries=${targetConfig.registries.length}`); details.push(`registries=${targetConfig.registries.length}`);
} }
if (targetConfig.accessLevel) details.push(`access=${targetConfig.accessLevel}`); if (targetConfig.accessLevel) details.push(`access=${targetConfig.accessLevel}`);
if (Array.isArray(targetConfig.images)) { if (targetConfig.engine) details.push(`engine=${targetConfig.engine}`);
details.push(`images=${targetConfig.images.length}`); 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; return details.length > 0 ? `${state} (${details.join(", ")})` : state;
} }
@@ -1338,6 +1408,29 @@ function parseCsv(value: string): string[] {
return result; 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 { function normalizeRegistryUrl(url: string): string {
let normalizedUrl = url.trim(); let normalizedUrl = url.trim();
if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) { if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
@@ -1391,6 +1484,7 @@ function buildConfigFixPrompt(
`- Use schemaVersion ${CURRENT_GITZONE_CLI_SCHEMA_VERSION} for ` + `- Use schemaVersion ${CURRENT_GITZONE_CLI_SCHEMA_VERSION} for ` +
"`@git.zone/cli`.", "`@git.zone/cli`.",
"- Use target-based release config: `release.targets.git`, `release.targets.npm`, and `release.targets.docker`.", "- 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`.", "- 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 add runtime legacy compatibility code. If legacy config exists, migrate it explicitly.",
"- Do not commit, release, install dependencies, or modify unrelated files.", "- Do not commit, release, install dependencies, or modify unrelated files.",
@@ -1513,9 +1607,24 @@ function validateCommitConfig(
} }
async function validateReleaseConfig( async function validateReleaseConfig(
releaseConfig: Record<string, any>, rawReleaseConfig: unknown,
smartconfigData: Record<string, any>,
findings: IDoctorFinding[], findings: IDoctorFinding[],
): Promise<void> { ): Promise<void> {
const releaseConfig = rawReleaseConfig === undefined ? {} : rawReleaseConfig;
if (!isPlainObject(releaseConfig)) {
findings.push({
level: "error",
message: `Release config must be an object, found ${
Array.isArray(releaseConfig) ? "array" : typeof releaseConfig
}`,
fix: Array.isArray(releaseConfig)
? "Run `gitzone config migrate` to move legacy registry arrays into release.targets.npm.registries."
: "Set @git.zone/cli.release to an object or remove it.",
});
return;
}
const confirmation = releaseConfig.confirmation; const confirmation = releaseConfig.confirmation;
if (confirmation === undefined || validConfirmationModes.includes(confirmation)) { if (confirmation === undefined || validConfirmationModes.includes(confirmation)) {
findings.push({ level: "ok", message: "Release confirmation mode is valid" }); findings.push({ level: "ok", message: "Release confirmation mode is valid" });
@@ -1554,7 +1663,7 @@ async function validateReleaseConfig(
const targets = releaseConfig.targets || {}; const targets = releaseConfig.targets || {};
await validateGitTarget(targets.git || {}, findings); await validateGitTarget(targets.git || {}, findings);
await validateNpmTarget(targets.npm || {}, findings); await validateNpmTarget(targets.npm || {}, findings);
validateDockerTarget(targets.docker || {}, findings); await validateDockerTarget(targets.docker || {}, smartconfigData, findings);
} }
async function validateGitTarget( async function validateGitTarget(
@@ -1718,31 +1827,171 @@ async function validateNpmAuth(
} }
} }
function validateDockerTarget( async function validateDockerTarget(
dockerTarget: Record<string, any>, dockerTarget: Record<string, any>,
smartconfigData: Record<string, any>,
findings: IDoctorFinding[], 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; const enabled = dockerTarget.enabled ?? false;
if (!enabled) { if (!enabled) {
findings.push({ level: "ok", message: "Docker release target is disabled" }); findings.push({ level: "ok", message: "Docker release target is disabled" });
return; return;
} }
if (!Array.isArray(dockerTarget.images) || dockerTarget.images.length === 0) { if (dockerTarget.engine !== "tsdocker") {
findings.push({ findings.push({
level: "error", level: "error",
message: "Docker release target is enabled without images", message: "Docker release target must use tsdocker",
fix: "Set release.targets.docker.images or disable release.targets.docker.enabled.", 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({ findings.push({
level: "ok", 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( async function validateDetectedProjectType(
cliConfig: Record<string, any>, cliConfig: Record<string, any>,
findings: IDoctorFinding[], findings: IDoctorFinding[],
@@ -1795,7 +2044,7 @@ export function showHelp(mode?: ICliMode): void {
{ name: "cli", description: "Configure CLI behavior interactively" }, { name: "cli", description: "Configure CLI behavior interactively" },
{ name: "release", description: "Configure release workflow interactively" }, { name: "release", description: "Configure release workflow interactively" },
{ name: "doctor", description: "Validate .smartconfig.json" }, { name: "doctor", description: "Validate .smartconfig.json" },
{ name: "fix [instructions]", description: "Use opencode to repair .smartconfig.json" }, { name: "fix [instructions]", description: "Repair .smartconfig.json" },
{ name: "get <path>", description: "Read a single config value" }, { name: "get <path>", description: "Read a single config value" },
{ name: "set <path> <value>", description: "Write a config value" }, { name: "set <path> <value>", description: "Write a config value" },
{ name: "unset <path>", description: "Delete a config value" }, { name: "unset <path>", description: "Delete a config value" },
@@ -1840,7 +2089,7 @@ export function showHelp(mode?: ICliMode): void {
console.log(" cli Configure CLI behavior interactively"); console.log(" cli Configure CLI behavior interactively");
console.log(" release Configure release workflow interactively"); console.log(" release Configure release workflow interactively");
console.log(" doctor Validate .smartconfig.json"); console.log(" doctor Validate .smartconfig.json");
console.log(" fix [instructions] Use opencode to repair .smartconfig.json"); console.log(" fix [instructions] Repair .smartconfig.json");
console.log(" get <path> Read a single config value"); console.log(" get <path> Read a single config value");
console.log(" set <path> <value> Write a config value"); console.log(" set <path> <value> Write a config value");
console.log(" unset <path> Delete a config value"); console.log(" unset <path> Delete a config value");
+13 -1
View File
@@ -1,6 +1,10 @@
import * as plugins from './mod.plugins.js'; import * as plugins from './mod.plugins.js';
import { FormatContext } from './classes.formatcontext.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 { Project } from '../classes.project.js';
import { FormatStats } from './classes.formatstats.js'; import { FormatStats } from './classes.formatstats.js';
@@ -19,6 +23,14 @@ export abstract class BaseFormatter {
abstract analyze(): Promise<IPlannedChange[]>; abstract analyze(): Promise<IPlannedChange[]>;
abstract applyChange(change: IPlannedChange): Promise<void>; abstract applyChange(change: IPlannedChange): Promise<void>;
get runsWithoutChanges(): boolean {
return false;
}
async validate(): Promise<IFormatWarning[]> {
return [];
}
async execute(changes: IPlannedChange[]): Promise<void> { async execute(changes: IPlannedChange[]): Promise<void> {
const startTime = this.stats.moduleStartTime(this.name); const startTime = this.stats.moduleStartTime(this.name);
this.stats.startModule(this.name); this.stats.startModule(this.name);
+66 -4
View File
@@ -1,7 +1,11 @@
import * as plugins from './mod.plugins.js'; import * as plugins from './mod.plugins.js';
import { FormatContext } from './classes.formatcontext.js'; import { FormatContext } from './classes.formatcontext.js';
import { BaseFormatter } from './classes.baseformatter.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 { getModuleIcon } from './interfaces.format.js';
import { logger } from '../gitzone.logging.js'; import { logger } from '../gitzone.logging.js';
import { DiffReporter } from './classes.diffreporter.js'; import { DiffReporter } from './classes.diffreporter.js';
@@ -42,15 +46,21 @@ export class FormatPlanner {
break; break;
} }
} }
const warnings = await module.validate();
plan.warnings.push(...warnings);
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
plan.warnings.push({ plan.warnings.push({
level: 'error', level: 'error',
message: `Failed to analyze module ${module.name}: ${error.message}`, message: `Failed to analyze module ${module.name}: ${errorMessage}`,
module: module.name, module: module.name,
}); });
} }
} }
plan.warnings.push(...this.detectConflictingChanges(plan.changes));
plan.summary.totalFiles = plan.summary.totalFiles =
plan.summary.filesAdded + plan.summary.filesAdded +
plan.summary.filesModified + plan.summary.filesModified +
@@ -65,11 +75,12 @@ export class FormatPlanner {
context: FormatContext, context: FormatContext,
): Promise<void> { ): Promise<void> {
const startTime = Date.now(); const startTime = Date.now();
const changesByModule = this.groupChangesByModule(plan.changes);
for (const module of modules) { 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...`); logger.log('info', `Executing ${module.name} formatter...`);
await module.execute(changes); await module.execute(changes);
} }
@@ -138,4 +149,55 @@ export class FormatPlanner {
return '❌'; 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 { 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 plugins from '../mod.plugins.js';
import * as paths from '../../paths.js'; import * as paths from '../../paths.js';
import { logger } from '../../gitzone.logging.js'; import { logger } from '../../gitzone.logging.js';
@@ -11,6 +11,10 @@ export class LicenseFormatter extends BaseFormatter {
return 'license'; return 'license';
} }
get runsWithoutChanges(): boolean {
return true;
}
async analyze(): Promise<IPlannedChange[]> { async analyze(): Promise<IPlannedChange[]> {
// License formatter only checks for incompatible licenses // License formatter only checks for incompatible licenses
// It does not modify any files, so return empty array // It does not modify any files, so return empty array
@@ -18,29 +22,34 @@ export class LicenseFormatter extends BaseFormatter {
return []; 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> { async execute(changes: IPlannedChange[]): Promise<void> {
const startTime = this.stats.moduleStartTime(this.name); const startTime = this.stats.moduleStartTime(this.name);
this.stats.startModule(this.name); this.stats.startModule(this.name);
try { try {
// Check if node_modules exists const licenseCheckResult = await this.checkLicenses();
const nodeModulesPath = plugins.path.join(paths.cwd, 'node_modules'); if (!licenseCheckResult) {
const nodeModulesExists = await plugins.smartfs
.directory(nodeModulesPath)
.exists();
if (!nodeModulesExists) {
logger.log('warn', 'No node_modules found. Skipping license check'); logger.log('warn', 'No node_modules found. Skipping license check');
return; return;
} }
// Run license check
const licenseChecker = await plugins.smartlegal.createLicenseChecker();
const licenseCheckResult = await licenseChecker.excludeLicenseWithinPath(
paths.cwd,
INCOMPATIBLE_LICENSES,
);
if (licenseCheckResult.failingModules.length === 0) { if (licenseCheckResult.failingModules.length === 0) {
logger.log('info', 'License check passed - no incompatible licenses found'); logger.log('info', 'License check passed - no incompatible licenses found');
} else { } else {
@@ -59,4 +68,23 @@ export class LicenseFormatter extends BaseFormatter {
async applyChange(change: IPlannedChange): Promise<void> { async applyChange(change: IPlannedChange): Promise<void> {
// No file changes for license formatter // 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,
);
}
} }
+54 -66
View File
@@ -56,7 +56,8 @@ export class PrettierFormatter extends BaseFormatter {
); );
allFiles.push(...filteredFiles); allFiles.push(...filteredFiles);
} catch (error) { } 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('/')); const rootLevelFiles = rootFiles.filter((f) => !f.includes('/'));
allFiles.push(...rootLevelFiles); allFiles.push(...rootLevelFiles);
} catch (error) { } 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) { } catch (error) {
// Skip files that can't be accessed // 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) { for (const file of validFiles) {
changes.push({ try {
type: 'modify', const fileExt = plugins.path.extname(file).toLowerCase();
path: file, if (!fileExt) {
module: this.name, continue;
description: 'Format with Prettier', }
});
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; return changes;
} }
@@ -127,9 +155,10 @@ export class PrettierFormatter extends BaseFormatter {
this.stats.recordFileOperation(this.name, change.type, true); this.stats.recordFileOperation(this.name, change.type, true);
} catch (error) { } catch (error) {
this.stats.recordFileOperation(this.name, change.type, false); this.stats.recordFileOperation(this.name, change.type, false);
const errorMessage = error instanceof Error ? error.message : String(error);
logger.log( logger.log(
'error', 'error',
`Failed to format ${change.path}: ${error.message}`, `Failed to format ${change.path}: ${errorMessage}`,
); );
// Don't throw - continue with other files // Don't throw - continue with other files
} }
@@ -192,28 +221,32 @@ export class PrettierFormatter extends BaseFormatter {
logVerbose(`No formatting changes for ${change.path}`); logVerbose(`No formatting changes for ${change.path}`);
} }
} catch (prettierError) { } catch (prettierError) {
const prettierErrorMessage = prettierError instanceof Error
? prettierError.message
: String(prettierError);
// Check if it's a parser error // Check if it's a parser error
if ( if (prettierErrorMessage.includes('No parser could be inferred')) {
prettierError.message && logVerbose(`Skipping ${change.path} - ${prettierErrorMessage}`);
prettierError.message.includes('No parser could be inferred')
) {
logVerbose(`Skipping ${change.path} - ${prettierError.message}`);
return; // Skip this file silently return; // Skip this file silently
} }
throw prettierError; throw prettierError;
} }
} catch (error) { } 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 // Log the full error stack for debugging mkdir issues
if (error.message && error.message.includes('mkdir')) { if (errorMessage.includes('mkdir')) {
logger.log( logger.log(
'error', '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 { } else {
logger.log( logger.log(
'error', 'error',
`Failed to format ${change.path}: ${error.message}`, `Failed to format ${change.path}: ${errorMessage}`,
); );
} }
throw error; 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> { async check(): Promise<ICheckResult> {
const changes = await this.analyze(); return await super.check();
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,
};
} }
} }
+289 -8
View File
@@ -22,6 +22,7 @@ import { TsconfigFormatter } from "./formatters/tsconfig.formatter.js";
import { PrettierFormatter } from "./formatters/prettier.formatter.js"; import { PrettierFormatter } from "./formatters/prettier.formatter.js";
import { ReadmeFormatter } from "./formatters/readme.formatter.js"; import { ReadmeFormatter } from "./formatters/readme.formatter.js";
import { CopyFormatter } from "./formatters/copy.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 * 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: { const createActiveFormatters = async (options: {
interactive: boolean; interactive: boolean;
jsonOutput: boolean; jsonOutput: boolean;
only?: string[];
skip?: string[];
}) => { }) => {
const project = await Project.fromCwd({ requireProjectType: false }); const project = await Project.fromCwd({ requireProjectType: false });
const context = new FormatContext(options); const context = new FormatContext(options);
@@ -107,11 +138,19 @@ const createActiveFormatters = async (options: {
([, FormatterClass]) => new FormatterClass(context, project), ([, 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) => { const activeFormatters = formatters.filter((formatter) => {
if (formatConfig.modules.only.length > 0) { if (onlyModules.length > 0) {
return formatConfig.modules.only.includes(formatter.name); return onlyModules.includes(formatter.name);
} }
if (formatConfig.modules.skip.includes(formatter.name)) { if (skipModules.includes(formatter.name)) {
return false; return false;
} }
return true; return true;
@@ -129,11 +168,15 @@ const buildFormatPlan = async (options: {
fromPlan?: string; fromPlan?: string;
interactive: boolean; interactive: boolean;
jsonOutput: boolean; jsonOutput: boolean;
only?: string[];
skip?: string[];
}) => { }) => {
const { context, planner, formatConfig, activeFormatters } = const { context, planner, formatConfig, activeFormatters } =
await createActiveFormatters({ await createActiveFormatters({
interactive: options.interactive, interactive: options.interactive,
jsonOutput: options.jsonOutput, jsonOutput: options.jsonOutput,
only: options.only,
skip: options.skip,
}); });
const plan = options.fromPlan 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 ( export let run = async (
options: { options: {
write?: boolean; write?: boolean;
@@ -194,8 +413,25 @@ export let run = async (
setVerboseMode(true); setVerboseMode(true);
} }
if (subcommand === "fix") {
await handleFormatFix(options, mode);
return;
}
const shouldWrite = options.write ?? options.dryRun === false; const shouldWrite = options.write ?? options.dryRun === false;
const treatAsPlan = subcommand === "plan"; 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) { if (mode.json && shouldWrite) {
printJson({ printJson({
@@ -212,7 +448,9 @@ export let run = async (
const formatConfig = await getFormatConfig(); const formatConfig = await getFormatConfig();
const interactive = const interactive =
options.interactive ?? (mode.interactive && formatConfig.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 { try {
const planBuilder = async () => { const planBuilder = async () => {
@@ -220,6 +458,8 @@ export let run = async (
fromPlan: options.fromPlan, fromPlan: options.fromPlan,
interactive, interactive,
jsonOutput: mode.json, jsonOutput: mode.json,
only,
skip,
}); });
}; };
@@ -231,7 +471,16 @@ export let run = async (
: await planBuilder(); : await planBuilder();
if (mode.json) { 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; return;
} }
@@ -251,6 +500,20 @@ export let run = async (
return; 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 // Show diffs if explicitly requested or before interactive write confirmation
const showDiffs = const showDiffs =
options.diff || (shouldWrite && interactive && !autoApprove); options.diff || (shouldWrite && interactive && !autoApprove);
@@ -314,7 +577,6 @@ export let run = async (
} }
}; };
import type { ICheckResult } from "./interfaces.format.js";
export type { ICheckResult }; export type { ICheckResult };
/** /**
@@ -363,7 +625,7 @@ export function showHelp(mode?: ICliMode): void {
if (mode?.json) { if (mode?.json) {
printJson({ printJson({
command: "format", command: "format",
usage: "gitzone format [plan] [options]", usage: "gitzone format [plan|check|fix] [options]",
description: description:
"Plans formatting changes by default and applies them only with --write.", "Plans formatting changes by default and applies them only with --write.",
flags: [ flags: [
@@ -393,19 +655,33 @@ export function showHelp(mode?: ICliMode): void {
flag: "--diff", flag: "--diff",
description: "Show per-file diffs before applying changes", 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" }, { flag: "--json", description: "Emit a read-only format plan as JSON" },
], ],
examples: [ examples: [
"gitzone format", "gitzone format",
"gitzone format plan --json", "gitzone format plan --json",
"gitzone format check",
"gitzone format --write --yes", "gitzone format --write --yes",
"gitzone format fix",
], ],
}); });
return; return;
} }
console.log(""); console.log("");
console.log("Usage: gitzone format [plan] [options]"); console.log("Usage: gitzone format [plan|check|fix] [options]");
console.log(""); console.log("");
console.log( console.log(
"Plans formatting changes by default and applies them only with --write.", "Plans formatting changes by default and applies them only with --write.",
@@ -424,11 +700,16 @@ export function showHelp(mode?: ICliMode): void {
console.log( console.log(
" --diff Show per-file diffs before applying changes", " --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(" --json Emit a read-only format plan as JSON");
console.log(""); console.log("");
console.log("Examples:"); console.log("Examples:");
console.log(" gitzone format"); console.log(" gitzone format");
console.log(" gitzone format plan --json"); console.log(" gitzone format plan --json");
console.log(" gitzone format check");
console.log(" gitzone format --write --yes"); console.log(" gitzone format --write --yes");
console.log(" gitzone format fix");
console.log(""); console.log("");
} }
+8 -11
View File
@@ -1,3 +1,9 @@
export type IFormatWarning = {
level: 'info' | 'warning' | 'error';
message: string;
module: string;
};
export type IFormatPlan = { export type IFormatPlan = {
summary: { summary: {
totalFiles: number; totalFiles: number;
@@ -5,17 +11,8 @@ export type IFormatPlan = {
filesModified: number; filesModified: number;
filesRemoved: number; filesRemoved: number;
}; };
changes: Array<{ changes: IPlannedChange[];
type: 'create' | 'modify' | 'delete'; warnings: IFormatWarning[];
path: string;
module: string;
description: string;
}>;
warnings: Array<{
level: 'info' | 'warning' | 'error';
message: string;
module: string;
}>;
}; };
export type IPlannedChange = { export type IPlannedChange = {
+46 -23
View File
@@ -107,7 +107,7 @@ export const run = async (argvArg: any) => {
npmResults.push(...(await runNpmTarget(smartshellInstance, workflow))); npmResults.push(...(await runNpmTarget(smartshellInstance, workflow)));
} }
if (workflow.targets.includes("docker")) { if (workflow.targets.includes("docker")) {
dockerResults.push(...(await runDockerTarget(smartshellInstance, workflow, newVersion))); dockerResults.push(...(await runDockerTarget(smartshellInstance, workflow)));
} }
printReleaseSummary(newVersion, gitResults, npmResults, dockerResults); printReleaseSummary(newVersion, gitResults, npmResults, dockerResults);
@@ -262,31 +262,43 @@ async function runNpmTarget(
async function runDockerTarget( async function runDockerTarget(
smartshellInstance: plugins.smartshell.Smartshell, smartshellInstance: plugins.smartshell.Smartshell,
workflow: IResolvedReleaseWorkflow, workflow: IResolvedReleaseWorkflow,
newVersion: string,
): Promise<ITargetResult[]> { ): Promise<ITargetResult[]> {
if (!workflow.dockerEnabled) { if (!workflow.dockerEnabled) {
return [{ target: "docker", status: "skipped", message: "disabled" }]; return [{ target: "docker", status: "skipped", message: "disabled" }];
} }
if (workflow.dockerImages.length === 0) {
return [{ target: "docker", status: "failed", message: "no images configured" }];
}
const results: ITargetResult[] = []; const command = buildTsdockerPushCommand(workflow);
for (const imageTemplate of workflow.dockerImages) { const result = await smartshellInstance.exec(command);
const image = imageTemplate.replaceAll("{{version}}", newVersion); const output = `${result.stdout || ""}\n${(result as any).stderr || ""}\n${(result as any).combinedOutput || ""}`;
const buildResult = await smartshellInstance.exec(`docker build -t ${shellQuote(image)} .`); return [{
if (buildResult.exitCode !== 0) { target: workflow.dockerPatterns.length > 0
results.push({ target: image, status: "failed", message: "docker build failed" }); ? `tsdocker:${workflow.dockerPatterns.join(",")}`
continue; : "tsdocker",
} status: result.exitCode === 0 ? "success" : "failed",
const pushResult = await smartshellInstance.exec(`docker push ${shellQuote(image)}`); message: result.exitCode === 0 ? undefined : firstMeaningfulLine(output),
results.push({ }];
target: image, }
status: pushResult.exitCode === 0 ? "success" : "failed",
message: pushResult.exitCode === 0 ? undefined : "docker push failed", function buildTsdockerPushCommand(workflow: IResolvedReleaseWorkflow): string {
}); const commandParts = ["tsdocker", "push"];
if (workflow.dockerNoBuild) {
commandParts.push("--no-build");
} }
return results; if (workflow.dockerCached) {
commandParts.push("--cached");
}
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 { 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"}`); console.log(`npm registries: ${workflow.npmRegistries.length > 0 ? workflow.npmRegistries.join(", ") : "none"}`);
} }
if (workflow.targets.includes("docker")) { 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(""); 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( function printReleaseSummary(
newVersion: string, newVersion: string,
gitResults: ITargetResult[], gitResults: ITargetResult[],
@@ -365,7 +388,7 @@ export function showHelp(mode?: ICliMode): void {
{ flag: "-p, --push", description: "Enable the git release target" }, { flag: "-p, --push", description: "Enable the git release target" },
{ flag: "--target <names>", description: "Release only selected targets: git,npm,docker" }, { flag: "--target <names>", description: "Release only selected targets: git,npm,docker" },
{ flag: "--npm", description: "Enable the npm release target" }, { 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: "--no-publish", description: "Run release core and git target only" },
{ flag: "--plan", description: "Show resolved workflow without mutating files" }, { 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(" -p, --push Enable the git release target");
console.log(" --target <names> Release only selected targets: git,npm,docker"); console.log(" --target <names> Release only selected targets: git,npm,docker");
console.log(" --npm Enable the npm release target"); 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(" --no-publish Run release core and git target only");
console.log(" --major|--minor|--patch Override inferred semver level"); console.log(" --major|--minor|--patch Override inferred semver level");
console.log(" --plan Show resolved workflow without mutating files"); 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 commit recommend --json");
console.log(" gitzone release --plan"); console.log(" gitzone release --plan");
console.log(" gitzone format plan --json"); console.log(" gitzone format plan --json");
console.log(" gitzone format check");
console.log(" gitzone services set mongodb,minio"); console.log(" gitzone services set mongodb,minio");
console.log(" gitzone tools update"); console.log(" gitzone tools update");
console.log(""); console.log("");