Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cba50b56e | |||
| 41ed17317c | |||
| db3b75a65d | |||
| d5027174dd | |||
| 5da1660c3e | |||
| 337fe2c64c | |||
| ad7f2742ff | |||
| b234ecc12a | |||
| 278df40ba7 | |||
| 6f0928e7c7 | |||
| 26effadcc9 | |||
| c38e94bcf3 | |||
| b9b51f29d1 | |||
| a3ad48368d | |||
| c10b764c0a |
+2
-1
@@ -63,7 +63,8 @@
|
|||||||
},
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"images": []
|
"engine": "tsdocker",
|
||||||
|
"patterns": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,60 @@
|
|||||||
## Pending
|
## Pending
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-05-24 - 2.19.4
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- refresh dependency resolution for global installs to pick up the fixed `@push.rocks/smartgit` release
|
||||||
|
- Refreshes the lockfile so `@git.zone/tsdoc` resolves `@push.rocks/smartgit@3.3.3`.
|
||||||
|
|
||||||
|
## 2026-05-24 - 2.19.3
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- handle empty commit analysis as a clean no-op
|
||||||
|
- Uses `@git.zone/tsdoc`'s explicit `NoChangesError` signal.
|
||||||
|
- Makes `gitzone commit` and `gitzone commit recommend --json` return cleanly when no uncommitted changes exist.
|
||||||
|
|
||||||
|
## 2026-05-23 - 2.19.2
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- repair `gitzone tools update` for pnpm 11 global installs
|
||||||
|
- Detects and migrates legacy pnpm global roots such as `PNPM_HOME/global/5`.
|
||||||
|
- Runs global pnpm maintenance with project package-manager switching disabled.
|
||||||
|
- Refreshes pnpm v10 and v11 command shims before deleting stale managed global roots.
|
||||||
|
|
||||||
|
## 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
|
## 2026-05-10 - 2.17.0
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@git.zone/cli",
|
"name": "@git.zone/cli",
|
||||||
"private": false,
|
"private": false,
|
||||||
"version": "2.17.0",
|
"version": "2.19.4",
|
||||||
"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",
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
"@types/node": "^25.4.0"
|
"@types/node": "^25.4.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git.zone/tsdoc": "^2.0.0",
|
"@git.zone/tsdoc": "^2.0.6",
|
||||||
"@git.zone/tspublish": "^1.11.2",
|
"@git.zone/tspublish": "^1.11.2",
|
||||||
"@push.rocks/commitinfo": "^1.0.12",
|
"@push.rocks/commitinfo": "^1.0.12",
|
||||||
"@push.rocks/early": "^4.0.4",
|
"@push.rocks/early": "^4.0.4",
|
||||||
@@ -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"
|
||||||
|
|||||||
Generated
+1842
-204
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -266,6 +282,9 @@ gitzone config release
|
|||||||
# Validate schema, legacy keys, release targets, registries, and npm auth
|
# Validate schema, legacy keys, release targets, registries, and npm auth
|
||||||
gitzone config doctor
|
gitzone config doctor
|
||||||
|
|
||||||
|
# Use opencode to repair configuration issues found by doctor
|
||||||
|
gitzone config fix
|
||||||
|
|
||||||
# Read the npm release target registries
|
# Read the npm release target registries
|
||||||
gitzone config get release.targets.npm.registries
|
gitzone config get release.targets.npm.registries
|
||||||
|
|
||||||
@@ -290,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.
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@git.zone/cli',
|
name: '@git.zone/cli',
|
||||||
version: '2.17.0',
|
version: '2.19.4',
|
||||||
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
@@ -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
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+32
-4
@@ -9,6 +9,13 @@ import { getCliMode, printJson, runWithSuppressedOutput } from "../helpers.climo
|
|||||||
import { appendPendingChangelogEntry } from "../helpers.changelog.js";
|
import { appendPendingChangelogEntry } from "../helpers.changelog.js";
|
||||||
import { resolveCommitWorkflow, type IResolvedCommitWorkflow } from "../helpers.workflow.js";
|
import { resolveCommitWorkflow, type IResolvedCommitWorkflow } from "../helpers.workflow.js";
|
||||||
|
|
||||||
|
const isNoChangesError = (error: unknown): boolean => {
|
||||||
|
return (
|
||||||
|
error instanceof plugins.tsdoc.NoChangesError ||
|
||||||
|
(error instanceof Error && error.name === "NoChangesError")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const run = async (argvArg: any) => {
|
export const run = async (argvArg: any) => {
|
||||||
const mode = await getCliMode(argvArg);
|
const mode = await getCliMode(argvArg);
|
||||||
const subcommand = argvArg._?.[1];
|
const subcommand = argvArg._?.[1];
|
||||||
@@ -65,7 +72,15 @@ export const run = async (argvArg: any) => {
|
|||||||
await runCommandStep(smartshellInstance, "Running build", workflow.buildCommand);
|
await runCommandStep(smartshellInstance, "Running build", workflow.buildCommand);
|
||||||
break;
|
break;
|
||||||
case "analyze":
|
case "analyze":
|
||||||
nextCommitObject = await runAnalyzeStep();
|
try {
|
||||||
|
nextCommitObject = await runAnalyzeStep();
|
||||||
|
} catch (error) {
|
||||||
|
if (isNoChangesError(error)) {
|
||||||
|
logger.log("info", "No uncommitted changes found. Nothing to commit.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
answerBucket = await buildAnswerBucket(nextCommitObject, workflow, mode, argvArg);
|
answerBucket = await buildAnswerBucket(nextCommitObject, workflow, mode, argvArg);
|
||||||
break;
|
break;
|
||||||
case "changelog":
|
case "changelog":
|
||||||
@@ -284,9 +299,22 @@ async function handleRecommend(mode: ICliMode): Promise<void> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const recommendation = mode.json
|
let recommendation: any;
|
||||||
? await runWithSuppressedOutput(recommendationBuilder)
|
try {
|
||||||
: await recommendationBuilder();
|
recommendation = mode.json
|
||||||
|
? await runWithSuppressedOutput(recommendationBuilder)
|
||||||
|
: await recommendationBuilder();
|
||||||
|
} catch (error) {
|
||||||
|
if (isNoChangesError(error)) {
|
||||||
|
if (mode.json) {
|
||||||
|
printJson({ ok: true, noChanges: true });
|
||||||
|
} else {
|
||||||
|
logger.log("info", "No uncommitted changes found.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
if (mode.json) {
|
if (mode.json) {
|
||||||
printJson(recommendation);
|
printJson(recommendation);
|
||||||
|
|||||||
+410
-26
@@ -117,6 +117,9 @@ export const run = async (argvArg: any) => {
|
|||||||
case "doctor":
|
case "doctor":
|
||||||
await handleDoctor(mode);
|
await handleDoctor(mode);
|
||||||
break;
|
break;
|
||||||
|
case "fix":
|
||||||
|
await handleFix(argvArg, mode);
|
||||||
|
break;
|
||||||
case "migrate":
|
case "migrate":
|
||||||
await handleMigrate(value, mode);
|
await handleMigrate(value, mode);
|
||||||
break;
|
break;
|
||||||
@@ -165,6 +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", 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" },
|
||||||
@@ -213,6 +217,9 @@ async function handleInteractiveMenu(): Promise<void> {
|
|||||||
case "doctor":
|
case "doctor":
|
||||||
await handleDoctor(defaultCliMode);
|
await handleDoctor(defaultCliMode);
|
||||||
break;
|
break;
|
||||||
|
case "fix":
|
||||||
|
await handleFix({ _: ["config", "fix"] }, defaultCliMode);
|
||||||
|
break;
|
||||||
case "help":
|
case "help":
|
||||||
showHelp();
|
showHelp();
|
||||||
break;
|
break;
|
||||||
@@ -786,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),
|
||||||
});
|
});
|
||||||
@@ -853,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);
|
||||||
@@ -890,6 +925,123 @@ async function handleRelease(mode: ICliMode): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDoctor(mode: ICliMode): Promise<void> {
|
async function handleDoctor(mode: ICliMode): Promise<void> {
|
||||||
|
const findings = await collectDoctorFindings();
|
||||||
|
printDoctorResult(findings, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFix(argvArg: any, mode: ICliMode): Promise<void> {
|
||||||
|
if (mode.json) {
|
||||||
|
printJson({
|
||||||
|
ok: false,
|
||||||
|
error: "JSON output is not supported for `gitzone config fix`. Use `gitzone config doctor --json` for machine-readable diagnostics.",
|
||||||
|
});
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let findings = await collectDoctorFindings();
|
||||||
|
let counts = countDoctorFindings(findings);
|
||||||
|
const extraInstructions = (argvArg._?.slice(2).join(" ") || "").trim();
|
||||||
|
const force = Boolean(argvArg.force);
|
||||||
|
|
||||||
|
if (counts.error === 0 && counts.warn === 0 && !extraInstructions && !force) {
|
||||||
|
plugins.logger.log(
|
||||||
|
"success",
|
||||||
|
"Configuration doctor found no issues. Use `gitzone config fix --force` to run opencode anyway.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mode.yes) {
|
||||||
|
if (!mode.interactive) {
|
||||||
|
throw new Error("Config fix requires an interactive terminal or `-y` to run non-interactively.");
|
||||||
|
}
|
||||||
|
const confirmed = await plugins.smartinteract.SmartInteract.getCliConfirmation(
|
||||||
|
`Run configuration fixes for .smartconfig.json? (${counts.error} error, ${counts.warn} warning)`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
plugins.logger.log("info", "Config fix cancelled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = [
|
||||||
|
"run",
|
||||||
|
"--title",
|
||||||
|
"gitzone config fix",
|
||||||
|
"--dir",
|
||||||
|
process.cwd(),
|
||||||
|
];
|
||||||
|
if (mode.yes) {
|
||||||
|
opencodeArgs.push("--dangerously-skip-permissions");
|
||||||
|
}
|
||||||
|
opencodeArgs.push(buildConfigFixPrompt(findings, extraInstructions));
|
||||||
|
|
||||||
|
plugins.logger.log("info", "Starting opencode configuration fix...");
|
||||||
|
const smartshellInstance = new plugins.smartshell.Smartshell({
|
||||||
|
executor: "bash",
|
||||||
|
sourceFilePaths: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
let result: plugins.smartshell.IExecResult;
|
||||||
|
try {
|
||||||
|
result = await smartshellInstance.execSpawn("opencode", opencodeArgs, {
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to run opencode: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
plugins.logger.log("error", `opencode exited with code ${result.exitCode}`);
|
||||||
|
process.exitCode = result.exitCode || 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await formatSmartconfigWithDiff(mode);
|
||||||
|
const finalFindings = await collectDoctorFindings();
|
||||||
|
printDoctorResult(finalFindings, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function 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[]> {
|
||||||
const findings: IDoctorFinding[] = [];
|
const findings: IDoctorFinding[] = [];
|
||||||
const smartconfigPath = getSmartconfigPath();
|
const smartconfigPath = getSmartconfigPath();
|
||||||
const smartconfigExists = await plugins.smartfs.file(smartconfigPath).exists();
|
const smartconfigExists = await plugins.smartfs.file(smartconfigPath).exists();
|
||||||
@@ -900,7 +1052,7 @@ async function handleDoctor(mode: ICliMode): Promise<void> {
|
|||||||
message: ".smartconfig.json does not exist",
|
message: ".smartconfig.json does not exist",
|
||||||
fix: "Run `gitzone config project` to create project basics.",
|
fix: "Run `gitzone config project` to create project basics.",
|
||||||
});
|
});
|
||||||
return printDoctorResult(findings, mode);
|
return findings;
|
||||||
}
|
}
|
||||||
|
|
||||||
let smartconfigData: Record<string, any>;
|
let smartconfigData: Record<string, any>;
|
||||||
@@ -912,7 +1064,7 @@ async function handleDoctor(mode: ICliMode): Promise<void> {
|
|||||||
message: ".smartconfig.json is not valid JSON",
|
message: ".smartconfig.json is not valid JSON",
|
||||||
fix: error instanceof Error ? error.message : String(error),
|
fix: error instanceof Error ? error.message : String(error),
|
||||||
});
|
});
|
||||||
return printDoctorResult(findings, mode);
|
return findings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cliConfig = getCliConfigValueFromData(smartconfigData, "") || {};
|
const cliConfig = getCliConfigValueFromData(smartconfigData, "") || {};
|
||||||
@@ -956,9 +1108,9 @@ async function handleDoctor(mode: ICliMode): Promise<void> {
|
|||||||
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);
|
||||||
|
|
||||||
printDoctorResult(findings, mode);
|
return findings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1204,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1251,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://")) {
|
||||||
@@ -1274,14 +1454,57 @@ function getDefaultEnabledTargets(currentTargets: Record<string, any>): string[]
|
|||||||
return enabledTargets;
|
return enabledTargets;
|
||||||
}
|
}
|
||||||
|
|
||||||
function printDoctorResult(findings: IDoctorFinding[], mode: ICliMode): void {
|
function countDoctorFindings(
|
||||||
const counts = findings.reduce(
|
findings: IDoctorFinding[],
|
||||||
|
): Record<TDoctorFindingLevel, number> {
|
||||||
|
return findings.reduce(
|
||||||
(accumulator, finding) => {
|
(accumulator, finding) => {
|
||||||
accumulator[finding.level] += 1;
|
accumulator[finding.level] += 1;
|
||||||
return accumulator;
|
return accumulator;
|
||||||
},
|
},
|
||||||
{ ok: 0, warn: 0, error: 0 } as Record<TDoctorFindingLevel, number>,
|
{ ok: 0, warn: 0, error: 0 } as Record<TDoctorFindingLevel, number>,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfigFixPrompt(
|
||||||
|
findings: IDoctorFinding[],
|
||||||
|
extraInstructions: string,
|
||||||
|
): string {
|
||||||
|
const promptParts = [
|
||||||
|
"Other /c-* commands can be found at ~/.config/opencode/commands/*",
|
||||||
|
"# gitzone config fix",
|
||||||
|
"",
|
||||||
|
`Working directory: ${process.cwd()}`,
|
||||||
|
"",
|
||||||
|
"Repair the project configuration so `gitzone config doctor --json` passes.",
|
||||||
|
"",
|
||||||
|
"Rules:",
|
||||||
|
"- Read `.smartconfig.json`, `package.json`, and nearby project metadata before editing.",
|
||||||
|
"- Keep gitzone CLI config under `@git.zone/cli` in `.smartconfig.json`.",
|
||||||
|
`- Use schemaVersion ${CURRENT_GITZONE_CLI_SCHEMA_VERSION} for ` +
|
||||||
|
"`@git.zone/cli`.",
|
||||||
|
"- Use target-based release config: `release.targets.git`, `release.targets.npm`, and `release.targets.docker`.",
|
||||||
|
"- Docker release targets must use `release.targets.docker.engine = \"tsdocker\"`; Docker registries belong under `@git.zone/tsdocker`.",
|
||||||
|
"- Keep npm registries only at `@git.zone/cli.release.targets.npm.registries`.",
|
||||||
|
"- Do not add runtime legacy compatibility code. If legacy config exists, migrate it explicitly.",
|
||||||
|
"- Do not commit, release, install dependencies, or modify unrelated files.",
|
||||||
|
"- Use pnpm commands only if commands are needed.",
|
||||||
|
"- Run `gitzone config doctor --json` after changes and keep fixing until no errors remain.",
|
||||||
|
"- Run `git diff --check` after changes to catch whitespace problems.",
|
||||||
|
"",
|
||||||
|
"Current doctor findings:",
|
||||||
|
JSON.stringify(findings, null, 2),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (extraInstructions) {
|
||||||
|
promptParts.push("", "Additional user instructions:", extraInstructions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return promptParts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function printDoctorResult(findings: IDoctorFinding[], mode: ICliMode): void {
|
||||||
|
const counts = countDoctorFindings(findings);
|
||||||
|
|
||||||
if (mode.json) {
|
if (mode.json) {
|
||||||
printJson({
|
printJson({
|
||||||
@@ -1384,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" });
|
||||||
@@ -1425,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(
|
||||||
@@ -1589,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[],
|
||||||
@@ -1666,6 +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: "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" },
|
||||||
@@ -1689,6 +2068,8 @@ export function showHelp(mode?: ICliMode): void {
|
|||||||
"gitzone config show --json",
|
"gitzone config show --json",
|
||||||
"gitzone config project",
|
"gitzone config project",
|
||||||
"gitzone config doctor --json",
|
"gitzone config doctor --json",
|
||||||
|
"gitzone config fix",
|
||||||
|
"gitzone config fix -y",
|
||||||
"gitzone config get release.targets.npm.accessLevel",
|
"gitzone config get release.targets.npm.accessLevel",
|
||||||
"gitzone config set cli.interactive false",
|
"gitzone config set cli.interactive false",
|
||||||
"gitzone config set cli.output json",
|
"gitzone config set cli.output json",
|
||||||
@@ -1708,6 +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] 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");
|
||||||
@@ -1730,6 +2112,8 @@ export function showHelp(mode?: ICliMode): void {
|
|||||||
console.log(" gitzone config cli");
|
console.log(" gitzone config cli");
|
||||||
console.log(" gitzone config release");
|
console.log(" gitzone config release");
|
||||||
console.log(" gitzone config doctor --json");
|
console.log(" gitzone config doctor --json");
|
||||||
|
console.log(" gitzone config fix");
|
||||||
|
console.log(" gitzone config fix -y");
|
||||||
console.log(" gitzone config get release.targets.npm.accessLevel");
|
console.log(" gitzone config get release.targets.npm.accessLevel");
|
||||||
console.log(" gitzone config set cli.interactive false");
|
console.log(" gitzone config set cli.interactive false");
|
||||||
console.log(" gitzone config set cli.output json");
|
console.log(" gitzone config set cli.output json");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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("");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import * as plugins from "./mod.plugins.js";
|
|||||||
export interface IInstalledPackage {
|
export interface IInstalledPackage {
|
||||||
name: string;
|
name: string;
|
||||||
version: string;
|
version: string;
|
||||||
|
globalDir?: string;
|
||||||
|
packagePath?: string;
|
||||||
|
legacy?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPackageUpdateInfo {
|
export interface IPackageUpdateInfo {
|
||||||
@@ -10,6 +13,8 @@ export interface IPackageUpdateInfo {
|
|||||||
currentVersion: string;
|
currentVersion: string;
|
||||||
latestVersion: string;
|
latestVersion: string;
|
||||||
needsUpdate: boolean;
|
needsUpdate: boolean;
|
||||||
|
needsMigration?: boolean;
|
||||||
|
globalDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPackageManagerInfo {
|
export interface IPackageManagerInfo {
|
||||||
@@ -19,18 +24,38 @@ export interface IPackageManagerInfo {
|
|||||||
needsUpdate: boolean;
|
needsUpdate: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ILegacyGlobalRootInfo {
|
||||||
|
globalDir: string;
|
||||||
|
packages: IInstalledPackage[];
|
||||||
|
unmanagedPackageNames: string[];
|
||||||
|
safeToDelete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ILegacyCleanupResult {
|
||||||
|
globalDir: string;
|
||||||
|
deleted: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IShimSyncResult {
|
||||||
|
name: string;
|
||||||
|
action: "updated" | "removed" | "skipped";
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPnpmListProject {
|
||||||
|
path?: string;
|
||||||
|
dependencies?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
export class PackageManagerUtil {
|
export class PackageManagerUtil {
|
||||||
private shell = new plugins.smartshell.Smartshell({
|
private shell = new plugins.smartshell.Smartshell({
|
||||||
executor: "bash",
|
executor: "bash",
|
||||||
});
|
});
|
||||||
|
private pnpmCommand: string | null | undefined;
|
||||||
|
|
||||||
public async detectPnpm(): Promise<boolean> {
|
public async detectPnpm(): Promise<boolean> {
|
||||||
try {
|
return Boolean(await this.getPnpmCommand());
|
||||||
const result = await this.shell.execSilent("pnpm --version 2>/dev/null");
|
|
||||||
return result.exitCode === 0 && Boolean(result.stdout.trim());
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getPnpmVersionInfo(): Promise<IPackageManagerInfo> {
|
public async getPnpmVersionInfo(): Promise<IPackageManagerInfo> {
|
||||||
@@ -45,53 +70,300 @@ export class PackageManagerUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentVersion = await this.getCurrentPnpmVersion();
|
const currentVersion = await this.getCurrentPnpmVersion();
|
||||||
const latestVersion = await this.getLatestVersion("pnpm", ["https://registry.npmjs.org"]);
|
const latestVersion = await this.getLatestVersion("pnpm", [
|
||||||
|
"https://registry.npmjs.org",
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
available: true,
|
available: true,
|
||||||
currentVersion,
|
currentVersion,
|
||||||
latestVersion,
|
latestVersion,
|
||||||
needsUpdate: latestVersion ? this.isNewerVersion(currentVersion, latestVersion) : false,
|
needsUpdate: latestVersion
|
||||||
|
? this.isNewerVersion(currentVersion, latestVersion)
|
||||||
|
: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getInstalledPackages(): Promise<IInstalledPackage[]> {
|
public async getInstalledPackages(): Promise<IInstalledPackage[]> {
|
||||||
const packages: IInstalledPackage[] = [];
|
const packageMap = new Map<string, IInstalledPackage>();
|
||||||
|
const currentPackages = await this.getCurrentInstalledPackages();
|
||||||
|
|
||||||
try {
|
for (const packageInfo of currentPackages) {
|
||||||
const result = await this.shell.execSilent("pnpm list -g --depth=0 --json 2>/dev/null || true");
|
packageMap.set(packageInfo.name, packageInfo);
|
||||||
const output = result.stdout.trim();
|
|
||||||
if (!output) {
|
|
||||||
return packages;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(output);
|
|
||||||
const dataArray = Array.isArray(data) ? data : [data];
|
|
||||||
for (const item of dataArray) {
|
|
||||||
const dependencies = item.dependencies || {};
|
|
||||||
for (const [name, info] of Object.entries(dependencies)) {
|
|
||||||
if (!name.startsWith("@git.zone/")) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
packages.push({
|
|
||||||
name,
|
|
||||||
version: (info as any).version || "unknown",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return packages;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return packages;
|
const legacyRoots = await this.getLegacyGlobalRoots();
|
||||||
|
for (const legacyRoot of legacyRoots) {
|
||||||
|
for (const packageInfo of legacyRoot.packages) {
|
||||||
|
if (!packageMap.has(packageInfo.name)) {
|
||||||
|
packageMap.set(packageInfo.name, packageInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(packageMap.values()).sort((packageA, packageB) =>
|
||||||
|
packageA.name.localeCompare(packageB.name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getLegacyGlobalRoots(): Promise<ILegacyGlobalRootInfo[]> {
|
||||||
|
const currentGlobalDir = await this.getCurrentGlobalDir();
|
||||||
|
const baseDirs = new Set<string>();
|
||||||
|
const pnpmHome = process.env.PNPM_HOME;
|
||||||
|
|
||||||
|
if (pnpmHome) {
|
||||||
|
baseDirs.add(plugins.path.join(pnpmHome, "global"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentGlobalDir) {
|
||||||
|
baseDirs.add(plugins.path.dirname(currentGlobalDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots: ILegacyGlobalRootInfo[] = [];
|
||||||
|
for (const baseDir of baseDirs) {
|
||||||
|
try {
|
||||||
|
const entries = await plugins.fs.readdir(baseDir, {
|
||||||
|
withFileTypes: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalDir = normalizePath(
|
||||||
|
plugins.path.join(baseDir, entry.name),
|
||||||
|
);
|
||||||
|
if (currentGlobalDir && pathsAreEqual(globalDir, currentGlobalDir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootInfo = await this.inspectGlobalRoot(globalDir, false);
|
||||||
|
if (rootInfo.packages.length > 0) {
|
||||||
|
roots.push(rootInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async cleanupLegacyGlobalRoots(): Promise<ILegacyCleanupResult[]> {
|
||||||
|
const legacyRoots = await this.getLegacyGlobalRoots();
|
||||||
|
if (legacyRoots.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPackageNames = new Set(
|
||||||
|
(await this.getCurrentInstalledPackages()).map(
|
||||||
|
(packageInfo) => packageInfo.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const cleanupResults: ILegacyCleanupResult[] = [];
|
||||||
|
|
||||||
|
for (const legacyRoot of legacyRoots) {
|
||||||
|
const missingPackageNames = legacyRoot.packages
|
||||||
|
.map((packageInfo) => packageInfo.name)
|
||||||
|
.filter((packageName) => !currentPackageNames.has(packageName));
|
||||||
|
|
||||||
|
if (missingPackageNames.length > 0) {
|
||||||
|
cleanupResults.push({
|
||||||
|
globalDir: legacyRoot.globalDir,
|
||||||
|
deleted: false,
|
||||||
|
reason: `kept because ${missingPackageNames.join(", ")} are not installed in the current pnpm global root`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!legacyRoot.safeToDelete) {
|
||||||
|
cleanupResults.push({
|
||||||
|
globalDir: legacyRoot.globalDir,
|
||||||
|
deleted: false,
|
||||||
|
reason:
|
||||||
|
legacyRoot.unmanagedPackageNames.length > 0
|
||||||
|
? `kept because it also contains ${legacyRoot.unmanagedPackageNames.join(", ")}`
|
||||||
|
: "kept because it is not a managed @git.zone-only global root",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockingShims = await this.getShimReferences(legacyRoot.globalDir);
|
||||||
|
if (blockingShims === null) {
|
||||||
|
cleanupResults.push({
|
||||||
|
globalDir: legacyRoot.globalDir,
|
||||||
|
deleted: false,
|
||||||
|
reason:
|
||||||
|
"kept because PNPM_HOME is not set, so command shims could not be verified",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockingShims.length > 0) {
|
||||||
|
cleanupResults.push({
|
||||||
|
globalDir: legacyRoot.globalDir,
|
||||||
|
deleted: false,
|
||||||
|
reason: `kept because command shims still reference it: ${blockingShims.join(", ")}`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await plugins.fs.rm(legacyRoot.globalDir, {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
cleanupResults.push({
|
||||||
|
globalDir: legacyRoot.globalDir,
|
||||||
|
deleted: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
cleanupResults.push({
|
||||||
|
globalDir: legacyRoot.globalDir,
|
||||||
|
deleted: false,
|
||||||
|
reason: `delete failed: ${(error as Error).message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanupResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async syncCurrentGlobalShims(): Promise<IShimSyncResult[]> {
|
||||||
|
const pnpmShimDirs = await this.getPnpmShimDirs();
|
||||||
|
if (!pnpmShimDirs) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "PNPM_HOME",
|
||||||
|
action: "skipped",
|
||||||
|
reason: "PNPM_HOME is not set",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: IShimSyncResult[] = [];
|
||||||
|
const currentBinNames = new Set<string>();
|
||||||
|
const currentPackages = await this.getCurrentInstalledPackages();
|
||||||
|
|
||||||
|
for (const packageInfo of currentPackages) {
|
||||||
|
if (!packageInfo.packagePath) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageJson = await readJson(
|
||||||
|
plugins.path.join(packageInfo.packagePath, "package.json"),
|
||||||
|
);
|
||||||
|
const binNames = getPackageBinNames(packageInfo.name, packageJson);
|
||||||
|
const nodeModulesDir = getNodeModulesDir(
|
||||||
|
packageInfo.packagePath,
|
||||||
|
packageInfo.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const binName of binNames) {
|
||||||
|
currentBinNames.add(binName);
|
||||||
|
const sourceShim = plugins.path.join(nodeModulesDir, ".bin", binName);
|
||||||
|
|
||||||
|
for (const pnpmShimDir of pnpmShimDirs) {
|
||||||
|
const destinationShim = plugins.path.join(pnpmShimDir, binName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sourceContent = await plugins.fs.readFile(sourceShim, "utf8");
|
||||||
|
const sourceStat = await plugins.fs.stat(sourceShim);
|
||||||
|
await plugins.fs.writeFile(
|
||||||
|
destinationShim,
|
||||||
|
rewriteShimForPnpmHome(sourceContent),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
await plugins.fs.chmod(destinationShim, sourceStat.mode);
|
||||||
|
results.push({
|
||||||
|
name: formatShimName(pnpmShimDir, binName),
|
||||||
|
action: "updated",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
name: formatShimName(pnpmShimDir, binName),
|
||||||
|
action: "skipped",
|
||||||
|
reason: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyRoots = await this.getLegacyGlobalRoots();
|
||||||
|
const legacyGlobalDirs = legacyRoots.map(
|
||||||
|
(legacyRoot) => legacyRoot.globalDir,
|
||||||
|
);
|
||||||
|
if (legacyGlobalDirs.length === 0) {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pnpmShimDir of pnpmShimDirs) {
|
||||||
|
try {
|
||||||
|
const entries = await plugins.fs.readdir(pnpmShimDir, {
|
||||||
|
withFileTypes: true,
|
||||||
|
});
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile() || currentBinNames.has(entry.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = plugins.path.join(pnpmShimDir, entry.name);
|
||||||
|
let content = "";
|
||||||
|
try {
|
||||||
|
content = await plugins.fs.readFile(filePath, "utf8");
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!legacyGlobalDirs.some((legacyGlobalDir) =>
|
||||||
|
content.includes(legacyGlobalDir),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await plugins.fs.rm(filePath, { force: true });
|
||||||
|
results.push({
|
||||||
|
name: formatShimName(pnpmShimDir, entry.name),
|
||||||
|
action: "removed",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
name: formatShimName(pnpmShimDir, entry.name),
|
||||||
|
action: "skipped",
|
||||||
|
reason: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
name: pnpmShimDir,
|
||||||
|
action: "skipped",
|
||||||
|
reason: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getLatestVersion(
|
public async getLatestVersion(
|
||||||
packageName: string,
|
packageName: string,
|
||||||
registries = ["https://verdaccio.lossless.digital", "https://registry.npmjs.org"],
|
registries = [
|
||||||
|
"https://verdaccio.lossless.digital",
|
||||||
|
"https://registry.npmjs.org",
|
||||||
|
],
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
for (const registry of registries) {
|
for (const registry of registries) {
|
||||||
const latest = await this.getLatestVersionFromRegistry(registry, packageName);
|
const latest = await this.getLatestVersionFromRegistry(
|
||||||
|
registry,
|
||||||
|
packageName,
|
||||||
|
);
|
||||||
if (latest) {
|
if (latest) {
|
||||||
return latest;
|
return latest;
|
||||||
}
|
}
|
||||||
@@ -99,23 +371,60 @@ export class PackageManagerUtil {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async installLatest(packageName: string): Promise<boolean> {
|
public async installLatest(
|
||||||
const packageSpecifier = `${packageName}@latest`;
|
packageName: string,
|
||||||
|
version = "latest",
|
||||||
|
): Promise<boolean> {
|
||||||
|
const pnpmCommand = await this.getPnpmCommand();
|
||||||
|
if (!pnpmCommand) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageSpecifier = `${packageName}@${version}`;
|
||||||
console.log(` Installing ${packageSpecifier} via pnpm...`);
|
console.log(` Installing ${packageSpecifier} via pnpm...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.shell.exec(`pnpm add -g ${shellQuote(packageSpecifier)}`);
|
const result = await this.shell.exec(
|
||||||
|
`${pnpmCommand} add -g ${shellQuote(packageSpecifier)}`,
|
||||||
|
);
|
||||||
return result.exitCode === 0;
|
return result.exitCode === 0;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async updatePnpm(targetVersion: string): Promise<boolean> {
|
||||||
|
const pnpmCommand = await this.getPnpmCommand();
|
||||||
|
if (!pnpmCommand) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const neutralDir = process.env.PNPM_HOME || "/tmp";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.shell.exec(
|
||||||
|
`${pnpmCommand} --dir ${shellQuote(neutralDir)} self-update ${shellQuote(targetVersion)}`,
|
||||||
|
);
|
||||||
|
this.pnpmCommand = undefined;
|
||||||
|
const currentVersion = await this.getCurrentPnpmVersion();
|
||||||
|
return (
|
||||||
|
result.exitCode === 0 &&
|
||||||
|
!this.isNewerVersion(currentVersion, targetVersion)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public isNewerVersion(current: string, latest: string): boolean {
|
public isNewerVersion(current: string, latest: string): boolean {
|
||||||
const currentParts = normalizeSemver(current);
|
const currentParts = normalizeSemver(current);
|
||||||
const latestParts = normalizeSemver(latest);
|
const latestParts = normalizeSemver(latest);
|
||||||
|
|
||||||
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < Math.max(currentParts.length, latestParts.length);
|
||||||
|
i++
|
||||||
|
) {
|
||||||
const currentPart = currentParts[i] || 0;
|
const currentPart = currentParts[i] || 0;
|
||||||
const latestPart = latestParts[i] || 0;
|
const latestPart = latestParts[i] || 0;
|
||||||
if (latestPart > currentPart) return true;
|
if (latestPart > currentPart) return true;
|
||||||
@@ -127,7 +436,10 @@ export class PackageManagerUtil {
|
|||||||
|
|
||||||
private async getCurrentPnpmVersion(): Promise<string> {
|
private async getCurrentPnpmVersion(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const result = await this.shell.execSilent("pnpm --version 2>/dev/null");
|
const result = await this.execPnpmSilent("--version 2>/dev/null");
|
||||||
|
if (!result) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
const versionMatch = result.stdout.trim().match(/(\d+\.\d+\.\d+)/);
|
const versionMatch = result.stdout.trim().match(/(\d+\.\d+\.\d+)/);
|
||||||
return versionMatch?.[1] || "unknown";
|
return versionMatch?.[1] || "unknown";
|
||||||
} catch {
|
} catch {
|
||||||
@@ -135,6 +447,265 @@ export class PackageManagerUtil {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getPnpmCommand(): Promise<string | null> {
|
||||||
|
if (this.pnpmCommand !== undefined) {
|
||||||
|
return this.pnpmCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
"pnpm --pm-on-fail=ignore",
|
||||||
|
"pnpm --config.manage-package-manager-versions=false",
|
||||||
|
"pnpm",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const result = await this.shell.execSilent(
|
||||||
|
`${candidate} --version 2>/dev/null`,
|
||||||
|
);
|
||||||
|
if (result.exitCode === 0 && Boolean(result.stdout.trim())) {
|
||||||
|
this.pnpmCommand = candidate;
|
||||||
|
return this.pnpmCommand;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Try the next supported pnpm invocation form.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pnpmCommand = null;
|
||||||
|
return this.pnpmCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async execPnpmSilent(commandArgs: string): Promise<any | null> {
|
||||||
|
const pnpmCommand = await this.getPnpmCommand();
|
||||||
|
if (!pnpmCommand) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.shell.execSilent(`${pnpmCommand} ${commandArgs}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPnpmListProjects(): Promise<IPnpmListProject[]> {
|
||||||
|
try {
|
||||||
|
const result = await this.execPnpmSilent(
|
||||||
|
"list -g --depth=0 --json 2>/dev/null || true",
|
||||||
|
);
|
||||||
|
const output = result?.stdout.trim();
|
||||||
|
if (!output) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(output);
|
||||||
|
return Array.isArray(data) ? data : [data];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCurrentGlobalDir(): Promise<string | null> {
|
||||||
|
const listProjects = await this.getPnpmListProjects();
|
||||||
|
for (const listProject of listProjects) {
|
||||||
|
if (typeof listProject.path === "string" && listProject.path.length > 0) {
|
||||||
|
return normalizeGlobalDir(listProject.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.execPnpmSilent("root -g 2>/dev/null");
|
||||||
|
const output = result?.stdout.trim();
|
||||||
|
return output ? normalizeGlobalDir(output) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCurrentInstalledPackages(): Promise<IInstalledPackage[]> {
|
||||||
|
const listProjects = await this.getPnpmListProjects();
|
||||||
|
const currentGlobalDir = await this.getCurrentGlobalDir();
|
||||||
|
const packageMap = new Map<string, IInstalledPackage>();
|
||||||
|
|
||||||
|
for (const listProject of listProjects) {
|
||||||
|
const globalDir = listProject.path
|
||||||
|
? normalizeGlobalDir(listProject.path)
|
||||||
|
: currentGlobalDir || undefined;
|
||||||
|
const dependencies = listProject.dependencies || {};
|
||||||
|
for (const [name, info] of Object.entries(dependencies)) {
|
||||||
|
if (!name.startsWith("@git.zone/")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
packageMap.set(name, {
|
||||||
|
name,
|
||||||
|
version: getDependencyVersion(info),
|
||||||
|
globalDir,
|
||||||
|
packagePath: getDependencyPackagePath(info),
|
||||||
|
legacy: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packageMap.size === 0 && currentGlobalDir) {
|
||||||
|
const currentRootInfo = await this.inspectGlobalRoot(
|
||||||
|
currentGlobalDir,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
for (const packageInfo of currentRootInfo.packages) {
|
||||||
|
packageMap.set(packageInfo.name, packageInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(packageMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async inspectGlobalRoot(
|
||||||
|
globalDir: string,
|
||||||
|
current: boolean,
|
||||||
|
): Promise<ILegacyGlobalRootInfo> {
|
||||||
|
const normalizedGlobalDir = normalizeGlobalDir(globalDir);
|
||||||
|
const rootPackageJson = await readJson(
|
||||||
|
plugins.path.join(normalizedGlobalDir, "package.json"),
|
||||||
|
);
|
||||||
|
const dependencies = getDependencyMap(rootPackageJson?.dependencies);
|
||||||
|
const packageMap = new Map<string, IInstalledPackage>();
|
||||||
|
|
||||||
|
for (const [name, spec] of Object.entries(dependencies)) {
|
||||||
|
if (!name.startsWith("@git.zone/")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
packageMap.set(name, {
|
||||||
|
name,
|
||||||
|
version:
|
||||||
|
(await this.getInstalledPackageVersion(normalizedGlobalDir, name)) ||
|
||||||
|
normalizeDependencySpec(spec),
|
||||||
|
globalDir: normalizedGlobalDir,
|
||||||
|
packagePath: getPackagePath(normalizedGlobalDir, name),
|
||||||
|
legacy: !current,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitZoneScopeDir = plugins.path.join(
|
||||||
|
normalizedGlobalDir,
|
||||||
|
"node_modules",
|
||||||
|
"@git.zone",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const entries = await plugins.fs.readdir(gitZoneScopeDir, {
|
||||||
|
withFileTypes: true,
|
||||||
|
});
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const name = `@git.zone/${entry.name}`;
|
||||||
|
packageMap.set(name, {
|
||||||
|
name,
|
||||||
|
version:
|
||||||
|
(await this.getInstalledPackageVersion(
|
||||||
|
normalizedGlobalDir,
|
||||||
|
name,
|
||||||
|
)) || "unknown",
|
||||||
|
globalDir: normalizedGlobalDir,
|
||||||
|
packagePath: getPackagePath(normalizedGlobalDir, name),
|
||||||
|
legacy: !current,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A pnpm global root may be empty or may not have node_modules materialized yet.
|
||||||
|
}
|
||||||
|
|
||||||
|
const dependencyNames = Object.keys(dependencies);
|
||||||
|
const unmanagedPackageNames = dependencyNames.filter(
|
||||||
|
(packageName) => !packageName.startsWith("@git.zone/"),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
globalDir: normalizedGlobalDir,
|
||||||
|
packages: Array.from(packageMap.values()).sort((packageA, packageB) =>
|
||||||
|
packageA.name.localeCompare(packageB.name),
|
||||||
|
),
|
||||||
|
unmanagedPackageNames,
|
||||||
|
safeToDelete:
|
||||||
|
!current &&
|
||||||
|
dependencyNames.length > 0 &&
|
||||||
|
unmanagedPackageNames.length === 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getInstalledPackageVersion(
|
||||||
|
globalDir: string,
|
||||||
|
packageName: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const packageJson = await readJson(
|
||||||
|
plugins.path.join(
|
||||||
|
globalDir,
|
||||||
|
"node_modules",
|
||||||
|
...packageName.split("/"),
|
||||||
|
"package.json",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return typeof packageJson?.version === "string"
|
||||||
|
? packageJson.version
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getShimReferences(
|
||||||
|
legacyGlobalDir: string,
|
||||||
|
): Promise<string[] | null> {
|
||||||
|
const pnpmShimDirs = await this.getPnpmShimDirs();
|
||||||
|
if (!pnpmShimDirs) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const references: string[] = [];
|
||||||
|
for (const pnpmShimDir of pnpmShimDirs) {
|
||||||
|
try {
|
||||||
|
const entries = await plugins.fs.readdir(pnpmShimDir, {
|
||||||
|
withFileTypes: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = plugins.path.join(pnpmShimDir, entry.name);
|
||||||
|
try {
|
||||||
|
const content = await plugins.fs.readFile(filePath, "utf8");
|
||||||
|
if (content.includes(legacyGlobalDir)) {
|
||||||
|
references.push(formatShimName(pnpmShimDir, entry.name));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore unreadable or non-text files in PNPM_HOME.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return references;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPnpmShimDirs(): Promise<string[] | null> {
|
||||||
|
const pnpmHome = process.env.PNPM_HOME;
|
||||||
|
if (!pnpmHome) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateDirs = [plugins.path.join(pnpmHome, "bin"), pnpmHome];
|
||||||
|
const shimDirs: string[] = [];
|
||||||
|
for (const candidateDir of candidateDirs) {
|
||||||
|
try {
|
||||||
|
const stat = await plugins.fs.stat(candidateDir);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
shimDirs.push(normalizePath(candidateDir));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore missing pnpm shim directories.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return shimDirs.length > 0 ? Array.from(new Set(shimDirs)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
private async getLatestVersionFromRegistry(
|
private async getLatestVersionFromRegistry(
|
||||||
registry: string,
|
registry: string,
|
||||||
packageName: string,
|
packageName: string,
|
||||||
@@ -164,6 +735,125 @@ export class PackageManagerUtil {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readJson(filePath: string): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
const content = await plugins.fs.readFile(filePath, "utf8");
|
||||||
|
return JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDependencyMap(value: unknown): Record<string, string> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return value as Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDependencyVersion(info: any): string {
|
||||||
|
if (info && typeof info === "object" && typeof info.version === "string") {
|
||||||
|
return info.version;
|
||||||
|
}
|
||||||
|
if (typeof info === "string") {
|
||||||
|
return normalizeDependencySpec(info);
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDependencyPackagePath(info: any): string | undefined {
|
||||||
|
return info && typeof info === "object" && typeof info.path === "string"
|
||||||
|
? normalizePath(info.path)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPackagePath(globalDir: string, packageName: string): string {
|
||||||
|
return plugins.path.join(
|
||||||
|
globalDir,
|
||||||
|
"node_modules",
|
||||||
|
...packageName.split("/"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPackageBinNames(packageName: string, packageJson: any): string[] {
|
||||||
|
const bin = packageJson?.bin;
|
||||||
|
if (typeof bin === "string") {
|
||||||
|
return [getDefaultBinName(packageName)];
|
||||||
|
}
|
||||||
|
if (!bin || typeof bin !== "object" || Array.isArray(bin)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.keys(bin)
|
||||||
|
.filter((binName) => binName.length > 0)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultBinName(packageName: string): string {
|
||||||
|
const packageNameParts = packageName.split("/");
|
||||||
|
return packageNameParts[packageNameParts.length - 1] || packageName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeModulesDir(packagePath: string, packageName: string): string {
|
||||||
|
return packageName.startsWith("@")
|
||||||
|
? plugins.path.dirname(plugins.path.dirname(packagePath))
|
||||||
|
: plugins.path.dirname(packagePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteShimForPnpmHome(content: string): string {
|
||||||
|
const targetMatch = content.match(/^# cmd-shim-target=(.+)$/m);
|
||||||
|
if (!targetMatch?.[1]) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const absoluteTarget = `"${escapeDoubleQuotedShell(targetMatch[1])}"`;
|
||||||
|
return content.replace(
|
||||||
|
/"\$basedir\/(?:\.\.\/)+store\/[^"\n]+"/g,
|
||||||
|
absoluteTarget,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeDoubleQuotedShell(value: string): string {
|
||||||
|
return value.replace(/["\\$`]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShimName(pnpmShimDir: string, binName: string): string {
|
||||||
|
const pnpmHome = process.env.PNPM_HOME;
|
||||||
|
if (!pnpmHome) {
|
||||||
|
return binName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativeName = plugins.path.relative(
|
||||||
|
pnpmHome,
|
||||||
|
plugins.path.join(pnpmShimDir, binName),
|
||||||
|
);
|
||||||
|
return relativeName && !relativeName.startsWith("..")
|
||||||
|
? relativeName
|
||||||
|
: binName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDependencySpec(spec: unknown): string {
|
||||||
|
if (typeof spec !== "string" || spec.length === 0) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
const versionMatch = spec.match(/\d+\.\d+\.\d+(?:[-+][\w.-]+)?/);
|
||||||
|
return versionMatch?.[0] || spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGlobalDir(globalDir: string): string {
|
||||||
|
const normalizedPath = normalizePath(globalDir);
|
||||||
|
return plugins.path.basename(normalizedPath) === "node_modules"
|
||||||
|
? plugins.path.dirname(normalizedPath)
|
||||||
|
: normalizedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(filePath: string): string {
|
||||||
|
return plugins.path.resolve(filePath).replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathsAreEqual(pathA: string, pathB: string): boolean {
|
||||||
|
return normalizePath(pathA) === normalizePath(pathB);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSemver(version: string): number[] {
|
function normalizeSemver(version: string): number[] {
|
||||||
return version
|
return version
|
||||||
.replace(/^[^\d]*/, "")
|
.replace(/^[^\d]*/, "")
|
||||||
|
|||||||
+226
-46
@@ -51,7 +51,9 @@ async function runUpdate(argvArg: any, mode: ICliMode): Promise<void> {
|
|||||||
|
|
||||||
const pnpmInfo = await pmUtil.getPnpmVersionInfo();
|
const pnpmInfo = await pmUtil.getPnpmVersionInfo();
|
||||||
if (!pnpmInfo.available) {
|
if (!pnpmInfo.available) {
|
||||||
console.log("pnpm is required for gitzone tools update, but it was not found.");
|
console.log(
|
||||||
|
"pnpm is required for gitzone tools update, but it was not found.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,18 +61,25 @@ async function runUpdate(argvArg: any, mode: ICliMode): Promise<void> {
|
|||||||
console.log(" Name Current Latest Status");
|
console.log(" Name Current Latest Status");
|
||||||
console.log(" ----------------------------------------------");
|
console.log(" ----------------------------------------------");
|
||||||
const latestPnpm = (pnpmInfo.latestVersion || "unknown").padEnd(12);
|
const latestPnpm = (pnpmInfo.latestVersion || "unknown").padEnd(12);
|
||||||
const pnpmStatus = pnpmInfo.latestVersion === null
|
const pnpmStatus =
|
||||||
? "? Version unknown"
|
pnpmInfo.latestVersion === null
|
||||||
: pnpmInfo.needsUpdate
|
? "? Version unknown"
|
||||||
? "Update available"
|
: pnpmInfo.needsUpdate
|
||||||
: "Up to date";
|
? "Update available"
|
||||||
console.log(` ${"pnpm".padEnd(9)}${pnpmInfo.currentVersion.padEnd(12)}${latestPnpm}${pnpmStatus}`);
|
: "Up to date";
|
||||||
|
console.log(
|
||||||
|
` ${"pnpm".padEnd(9)}${pnpmInfo.currentVersion.padEnd(12)}${latestPnpm}${pnpmStatus}`,
|
||||||
|
);
|
||||||
console.log("");
|
console.log("");
|
||||||
|
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
console.log("Using pnpm as the supported global package manager.\n");
|
console.log("Using pnpm as the supported global package manager.\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pnpmNeedsUpdate = Boolean(
|
||||||
|
pnpmInfo.latestVersion && pnpmInfo.needsUpdate,
|
||||||
|
);
|
||||||
|
|
||||||
const selfUpdated = await handleSelfUpdate(pmUtil, mode);
|
const selfUpdated = await handleSelfUpdate(pmUtil, mode);
|
||||||
if (selfUpdated) {
|
if (selfUpdated) {
|
||||||
return;
|
return;
|
||||||
@@ -78,39 +87,79 @@ async function runUpdate(argvArg: any, mode: ICliMode): Promise<void> {
|
|||||||
|
|
||||||
const installedPackages = await pmUtil.getInstalledPackages();
|
const installedPackages = await pmUtil.getInstalledPackages();
|
||||||
const packageInfos = await getPackageUpdateInfos(pmUtil, installedPackages);
|
const packageInfos = await getPackageUpdateInfos(pmUtil, installedPackages);
|
||||||
|
const legacyRoots = await pmUtil.getLegacyGlobalRoots();
|
||||||
|
const legacyCleanupNeeded = legacyRoots.length > 0;
|
||||||
|
|
||||||
if (packageInfos.length === 0) {
|
if (packageInfos.length === 0) {
|
||||||
console.log("No managed @git.zone packages found installed globally.");
|
console.log("No managed @git.zone packages found installed globally.");
|
||||||
return;
|
} else {
|
||||||
}
|
console.log("Installed @git.zone packages:\n");
|
||||||
|
|
||||||
console.log("Installed @git.zone packages:\n");
|
|
||||||
console.log(" Package Current Latest Status");
|
|
||||||
console.log(" ------------------------------------------------------------");
|
|
||||||
for (const packageInfo of packageInfos) {
|
|
||||||
const status = packageInfo.latestVersion === "unknown"
|
|
||||||
? "? Version unknown"
|
|
||||||
: packageInfo.needsUpdate
|
|
||||||
? "Update available"
|
|
||||||
: "Up to date";
|
|
||||||
console.log(
|
console.log(
|
||||||
` ${packageInfo.name.padEnd(28)}${packageInfo.currentVersion.padEnd(12)}${packageInfo.latestVersion.padEnd(12)}${status}`,
|
" Package Current Latest Status",
|
||||||
);
|
);
|
||||||
|
console.log(
|
||||||
|
" ------------------------------------------------------------",
|
||||||
|
);
|
||||||
|
for (const packageInfo of packageInfos) {
|
||||||
|
console.log(
|
||||||
|
` ${packageInfo.name.padEnd(28)}${packageInfo.currentVersion.padEnd(12)}${packageInfo.latestVersion.padEnd(12)}${getPackageStatus(packageInfo)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
await printMissingPackages(pmUtil, installedPackages);
|
||||||
}
|
}
|
||||||
console.log("");
|
|
||||||
|
|
||||||
await printMissingPackages(pmUtil, installedPackages);
|
const packagesToUpdate = packageInfos.filter(
|
||||||
|
(packageInfo) => packageInfo.needsUpdate || packageInfo.needsMigration,
|
||||||
|
);
|
||||||
|
const packagesToMigrate = packageInfos.filter(
|
||||||
|
(packageInfo) => packageInfo.needsMigration,
|
||||||
|
);
|
||||||
|
|
||||||
const packagesToUpdate = packageInfos.filter((packageInfo) => packageInfo.needsUpdate);
|
if (packagesToMigrate.length > 0) {
|
||||||
if (packagesToUpdate.length === 0) {
|
console.log(
|
||||||
|
`Detected ${packagesToMigrate.length} package(s) in legacy pnpm global roots.`,
|
||||||
|
);
|
||||||
|
if (verbose) {
|
||||||
|
for (const packageInfo of packagesToMigrate) {
|
||||||
|
console.log(
|
||||||
|
` ${packageInfo.name} -> ${packageInfo.globalDir || "unknown"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
} else if (legacyCleanupNeeded) {
|
||||||
|
console.log(
|
||||||
|
`Detected ${legacyRoots.length} legacy pnpm global root(s) for cleanup.`,
|
||||||
|
);
|
||||||
|
if (verbose) {
|
||||||
|
for (const legacyRoot of legacyRoots) {
|
||||||
|
console.log(` ${legacyRoot.globalDir}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
packagesToUpdate.length === 0 &&
|
||||||
|
!pnpmNeedsUpdate &&
|
||||||
|
!legacyCleanupNeeded
|
||||||
|
) {
|
||||||
console.log("All managed packages are up to date.");
|
console.log("All managed packages are up to date.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Found ${packagesToUpdate.length} package(s) with available updates.\n`);
|
const actionCount =
|
||||||
|
packagesToUpdate.length +
|
||||||
|
(pnpmNeedsUpdate ? 1 : 0) +
|
||||||
|
(legacyCleanupNeeded ? 1 : 0);
|
||||||
|
console.log(`Found ${actionCount} update action(s).\n`);
|
||||||
|
|
||||||
if (!mode.yes && !mode.interactive) {
|
if (!mode.yes && !mode.interactive) {
|
||||||
console.log("Run gitzone tools update -y to update without prompts.");
|
console.log(
|
||||||
|
"Run gitzone tools update -y to update, migrate, and cleanup without prompts.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +169,7 @@ async function runUpdate(argvArg: any, mode: ICliMode): Promise<void> {
|
|||||||
const answer = await interactInstance.askQuestion({
|
const answer = await interactInstance.askQuestion({
|
||||||
type: "confirm",
|
type: "confirm",
|
||||||
name: "confirmUpdate",
|
name: "confirmUpdate",
|
||||||
message: "Do you want to update these packages?",
|
message: "Do you want to update, migrate, and cleanup these tools?",
|
||||||
default: true,
|
default: true,
|
||||||
});
|
});
|
||||||
shouldUpdate = answer.value === true;
|
shouldUpdate = answer.value === true;
|
||||||
@@ -131,7 +180,35 @@ async function runUpdate(argvArg: any, mode: ICliMode): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await installPackages(pmUtil, packagesToUpdate.map((packageInfo) => packageInfo.name), "updated");
|
if (pnpmNeedsUpdate && pnpmInfo.latestVersion) {
|
||||||
|
console.log(`Updating pnpm to ${pnpmInfo.latestVersion}...`);
|
||||||
|
const success = await pmUtil.updatePnpm(pnpmInfo.latestVersion);
|
||||||
|
console.log(
|
||||||
|
success
|
||||||
|
? "pnpm updated successfully.\n"
|
||||||
|
: "pnpm update failed. Continuing with package updates.\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const installResult =
|
||||||
|
packagesToUpdate.length > 0
|
||||||
|
? await installPackages(
|
||||||
|
pmUtil,
|
||||||
|
packagesToUpdate.map((packageInfo) => ({
|
||||||
|
name: packageInfo.name,
|
||||||
|
version:
|
||||||
|
packageInfo.latestVersion !== "unknown"
|
||||||
|
? packageInfo.latestVersion
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
|
"updated",
|
||||||
|
)
|
||||||
|
: { successCount: 0, failCount: 0 };
|
||||||
|
|
||||||
|
if (packagesToUpdate.length > 0 || legacyCleanupNeeded) {
|
||||||
|
await syncCurrentGlobalShims(pmUtil);
|
||||||
|
await cleanupLegacyInstalls(pmUtil);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runInstall(argvArg: any, mode: ICliMode): Promise<void> {
|
async function runInstall(argvArg: any, mode: ICliMode): Promise<void> {
|
||||||
@@ -142,7 +219,9 @@ async function runInstall(argvArg: any, mode: ICliMode): Promise<void> {
|
|||||||
|
|
||||||
const pnpmAvailable = await pmUtil.detectPnpm();
|
const pnpmAvailable = await pmUtil.detectPnpm();
|
||||||
if (!pnpmAvailable) {
|
if (!pnpmAvailable) {
|
||||||
console.log("pnpm is required for gitzone tools install, but it was not found.");
|
console.log(
|
||||||
|
"pnpm is required for gitzone tools install, but it was not found.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,8 +230,12 @@ async function runInstall(argvArg: any, mode: ICliMode): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const installedPackages = await pmUtil.getInstalledPackages();
|
const installedPackages = await pmUtil.getInstalledPackages();
|
||||||
const installedNames = new Set(installedPackages.map((packageInfo) => packageInfo.name));
|
const installedNames = new Set(
|
||||||
const missingPackages = GITZONE_PACKAGES.filter((packageName) => !installedNames.has(packageName));
|
installedPackages.map((packageInfo) => packageInfo.name),
|
||||||
|
);
|
||||||
|
const missingPackages = GITZONE_PACKAGES.filter(
|
||||||
|
(packageName) => !installedNames.has(packageName),
|
||||||
|
);
|
||||||
|
|
||||||
if (missingPackages.length === 0) {
|
if (missingPackages.length === 0) {
|
||||||
console.log("All managed @git.zone packages are already installed.");
|
console.log("All managed @git.zone packages are already installed.");
|
||||||
@@ -163,7 +246,9 @@ async function runInstall(argvArg: any, mode: ICliMode): Promise<void> {
|
|||||||
|
|
||||||
if (!mode.yes && !mode.interactive) {
|
if (!mode.yes && !mode.interactive) {
|
||||||
await printPackageListWithLatest(pmUtil, missingPackages);
|
await printPackageListWithLatest(pmUtil, missingPackages);
|
||||||
console.log("Run gitzone tools install -y to install all missing packages without prompts.");
|
console.log(
|
||||||
|
"Run gitzone tools install -y to install all missing packages without prompts.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +295,9 @@ async function handleSelfUpdate(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(` @git.zone/cli ${currentVersion} -> ${latestVersion} Update available\n`);
|
console.log(
|
||||||
|
` @git.zone/cli ${currentVersion} -> ${latestVersion} Update available\n`,
|
||||||
|
);
|
||||||
|
|
||||||
if (!mode.yes && !mode.interactive) {
|
if (!mode.yes && !mode.interactive) {
|
||||||
console.log("Run gitzone tools update -y to update gitzone first.");
|
console.log("Run gitzone tools update -y to update gitzone first.");
|
||||||
@@ -236,11 +323,15 @@ async function handleSelfUpdate(
|
|||||||
|
|
||||||
const success = await pmUtil.installLatest("@git.zone/cli");
|
const success = await pmUtil.installLatest("@git.zone/cli");
|
||||||
if (!success) {
|
if (!success) {
|
||||||
console.log("\ngitzone self-update failed. Continuing with the current version.\n");
|
console.log(
|
||||||
|
"\ngitzone self-update failed. Continuing with the current version.\n",
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("\ngitzone has been updated. Re-run gitzone tools update to check remaining packages.");
|
console.log(
|
||||||
|
"\ngitzone has been updated. Re-run gitzone tools update to check remaining packages.",
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,17 +352,39 @@ async function getPackageUpdateInfos(
|
|||||||
needsUpdate: latestVersion
|
needsUpdate: latestVersion
|
||||||
? pmUtil.isNewerVersion(installedPackage.version, latestVersion)
|
? pmUtil.isNewerVersion(installedPackage.version, latestVersion)
|
||||||
: false,
|
: false,
|
||||||
|
needsMigration: installedPackage.legacy === true,
|
||||||
|
globalDir: installedPackage.globalDir,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return packageInfos;
|
return packageInfos;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPackageStatus(packageInfo: IPackageUpdateInfo): string {
|
||||||
|
if (packageInfo.latestVersion === "unknown") {
|
||||||
|
return "? Version unknown";
|
||||||
|
}
|
||||||
|
if (packageInfo.needsUpdate && packageInfo.needsMigration) {
|
||||||
|
return "Update + migrate";
|
||||||
|
}
|
||||||
|
if (packageInfo.needsUpdate) {
|
||||||
|
return "Update available";
|
||||||
|
}
|
||||||
|
if (packageInfo.needsMigration) {
|
||||||
|
return "Migrate global root";
|
||||||
|
}
|
||||||
|
return "Up to date";
|
||||||
|
}
|
||||||
|
|
||||||
async function printMissingPackages(
|
async function printMissingPackages(
|
||||||
pmUtil: PackageManagerUtil,
|
pmUtil: PackageManagerUtil,
|
||||||
installedPackages: IInstalledPackage[],
|
installedPackages: IInstalledPackage[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const installedNames = new Set(installedPackages.map((packageInfo) => packageInfo.name));
|
const installedNames = new Set(
|
||||||
const missingPackages = GITZONE_PACKAGES.filter((packageName) => !installedNames.has(packageName));
|
installedPackages.map((packageInfo) => packageInfo.name),
|
||||||
|
);
|
||||||
|
const missingPackages = GITZONE_PACKAGES.filter(
|
||||||
|
(packageName) => !installedNames.has(packageName),
|
||||||
|
);
|
||||||
if (missingPackages.length === 0) {
|
if (missingPackages.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -296,14 +409,18 @@ async function printPackageListWithLatest(
|
|||||||
|
|
||||||
async function installPackages(
|
async function installPackages(
|
||||||
pmUtil: PackageManagerUtil,
|
pmUtil: PackageManagerUtil,
|
||||||
packageNames: string[],
|
packageSpecs: Array<string | { name: string; version?: string }>,
|
||||||
action: "installed" | "updated",
|
action: "installed" | "updated",
|
||||||
): Promise<void> {
|
): Promise<{ successCount: number; failCount: number }> {
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
let failCount = 0;
|
let failCount = 0;
|
||||||
|
|
||||||
for (const packageName of packageNames) {
|
for (const packageSpec of packageSpecs) {
|
||||||
const success = await pmUtil.installLatest(packageName);
|
const packageName =
|
||||||
|
typeof packageSpec === "string" ? packageSpec : packageSpec.name;
|
||||||
|
const packageVersion =
|
||||||
|
typeof packageSpec === "string" ? undefined : packageSpec.version;
|
||||||
|
const success = await pmUtil.installLatest(packageName, packageVersion);
|
||||||
if (success) {
|
if (success) {
|
||||||
console.log(` ${packageName} ${action} successfully`);
|
console.log(` ${packageName} ${action} successfully`);
|
||||||
successCount++;
|
successCount++;
|
||||||
@@ -319,6 +436,56 @@ async function installPackages(
|
|||||||
} else {
|
} else {
|
||||||
console.log(`${successCount} package(s) ${action}, ${failCount} failed.`);
|
console.log(`${successCount} package(s) ${action}, ${failCount} failed.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { successCount, failCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupLegacyInstalls(
|
||||||
|
pmUtil: PackageManagerUtil,
|
||||||
|
): Promise<void> {
|
||||||
|
const cleanupResults = await pmUtil.cleanupLegacyGlobalRoots();
|
||||||
|
if (cleanupResults.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Legacy pnpm global roots:\n");
|
||||||
|
for (const cleanupResult of cleanupResults) {
|
||||||
|
if (cleanupResult.deleted) {
|
||||||
|
console.log(` ${cleanupResult.globalDir} deleted`);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
` ${cleanupResult.globalDir} kept (${cleanupResult.reason || "unknown reason"})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncCurrentGlobalShims(
|
||||||
|
pmUtil: PackageManagerUtil,
|
||||||
|
): Promise<void> {
|
||||||
|
const shimResults = await pmUtil.syncCurrentGlobalShims();
|
||||||
|
const changedResults = shimResults.filter(
|
||||||
|
(shimResult) => shimResult.action !== "skipped",
|
||||||
|
);
|
||||||
|
const skippedResults = shimResults.filter(
|
||||||
|
(shimResult) => shimResult.action === "skipped",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (changedResults.length === 0 && skippedResults.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Command shims:\n");
|
||||||
|
for (const shimResult of changedResults) {
|
||||||
|
console.log(` ${shimResult.name} ${shimResult.action}`);
|
||||||
|
}
|
||||||
|
for (const shimResult of skippedResults) {
|
||||||
|
console.log(
|
||||||
|
` ${shimResult.name} skipped (${shimResult.reason || "unknown reason"})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showHelp(mode?: ICliMode): void {
|
export function showHelp(mode?: ICliMode): void {
|
||||||
@@ -327,12 +494,21 @@ export function showHelp(mode?: ICliMode): void {
|
|||||||
name: "gitzone tools",
|
name: "gitzone tools",
|
||||||
usage: "gitzone tools <command> [options]",
|
usage: "gitzone tools <command> [options]",
|
||||||
commands: [
|
commands: [
|
||||||
{ name: "update", description: "Check and update globally installed @git.zone packages" },
|
{
|
||||||
{ name: "install", description: "Install missing managed @git.zone packages" },
|
name: "update",
|
||||||
|
description: "Check and update globally installed @git.zone packages",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "install",
|
||||||
|
description: "Install missing managed @git.zone packages",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
flags: [
|
flags: [
|
||||||
{ flag: "-y, --yes", description: "Run without confirmation prompts" },
|
{ flag: "-y, --yes", description: "Run without confirmation prompts" },
|
||||||
{ flag: "-v, --verbose", description: "Show package manager diagnostics" },
|
{
|
||||||
|
flag: "-v, --verbose",
|
||||||
|
description: "Show package manager diagnostics",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
packageManager: "pnpm",
|
packageManager: "pnpm",
|
||||||
managedPackages: GITZONE_PACKAGES,
|
managedPackages: GITZONE_PACKAGES,
|
||||||
@@ -344,8 +520,12 @@ export function showHelp(mode?: ICliMode): void {
|
|||||||
console.log("Usage: gitzone tools <command> [options]");
|
console.log("Usage: gitzone tools <command> [options]");
|
||||||
console.log("");
|
console.log("");
|
||||||
console.log("Commands:");
|
console.log("Commands:");
|
||||||
console.log(" update Check and update globally installed @git.zone packages");
|
console.log(
|
||||||
console.log(" install Install missing managed @git.zone packages");
|
" update Check and update globally installed @git.zone packages",
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
" install Install missing managed @git.zone packages",
|
||||||
|
);
|
||||||
console.log("");
|
console.log("");
|
||||||
console.log("Options:");
|
console.log("Options:");
|
||||||
console.log(" -y, --yes Run without confirmation prompts");
|
console.log(" -y, --yes Run without confirmation prompts");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as smartlog from '@push.rocks/smartlog';
|
|||||||
import * as smartlogDestinationLocal from '@push.rocks/smartlog-destination-local';
|
import * as smartlogDestinationLocal from '@push.rocks/smartlog-destination-local';
|
||||||
import * as smartconfig from '@push.rocks/smartconfig';
|
import * as smartconfig from '@push.rocks/smartconfig';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import * as fs from 'node:fs/promises';
|
||||||
import * as projectinfo from '@push.rocks/projectinfo';
|
import * as projectinfo from '@push.rocks/projectinfo';
|
||||||
import * as smartcli from '@push.rocks/smartcli';
|
import * as smartcli from '@push.rocks/smartcli';
|
||||||
import * as smartpath from '@push.rocks/smartpath';
|
import * as smartpath from '@push.rocks/smartpath';
|
||||||
@@ -22,6 +23,7 @@ export {
|
|||||||
smartlogDestinationLocal,
|
smartlogDestinationLocal,
|
||||||
smartconfig,
|
smartconfig,
|
||||||
path,
|
path,
|
||||||
|
fs,
|
||||||
projectinfo,
|
projectinfo,
|
||||||
smartcli,
|
smartcli,
|
||||||
smartpath,
|
smartpath,
|
||||||
|
|||||||
Reference in New Issue
Block a user