feat(cli): split commit and release into target-based workflows
This commit is contained in:
+36
-11
@@ -1,13 +1,12 @@
|
||||
{
|
||||
"szci": {
|
||||
"npmGlobalTools": [],
|
||||
"npmAccessLevel": "private",
|
||||
"npmRegistryUrl": "verdaccio.lossless.one"
|
||||
"@ship.zone/szci": {
|
||||
"npmGlobalTools": []
|
||||
},
|
||||
"tsdoc": {
|
||||
"@git.zone/tsdoc": {
|
||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||
},
|
||||
"@git.zone/cli": {
|
||||
"schemaVersion": 2,
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"githost": "gitlab.com",
|
||||
@@ -35,12 +34,38 @@
|
||||
"CI/CD"
|
||||
]
|
||||
},
|
||||
"commit": {
|
||||
"confirmation": "prompt",
|
||||
"steps": ["analyze", "changelog", "commit"]
|
||||
},
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
"confirmation": "prompt",
|
||||
"preflight": {
|
||||
"requireCleanTree": true,
|
||||
"test": false,
|
||||
"build": true
|
||||
},
|
||||
"targets": {
|
||||
"git": {
|
||||
"enabled": true,
|
||||
"remote": "origin",
|
||||
"pushBranch": true,
|
||||
"pushTags": true
|
||||
},
|
||||
"npm": {
|
||||
"enabled": true,
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public",
|
||||
"alreadyPublished": "success"
|
||||
},
|
||||
"docker": {
|
||||
"enabled": false,
|
||||
"images": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
"npmPackagename": "{{module.npmPackagename}}",
|
||||
"license": "{{module.license}}",
|
||||
"projectDomain": "{{module.projectDomain}}"
|
||||
},
|
||||
"release": {
|
||||
"targets": {
|
||||
"npm": {
|
||||
"registries": ["{{npmPrivateRegistry}}"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@ship.zone/szci": {
|
||||
@@ -18,7 +25,6 @@
|
||||
},
|
||||
"dockerBuildargEnvMap": {
|
||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
||||
},
|
||||
"npmRegistryUrl": "{{npmPrivateRegistry}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,11 @@ fileName: .smartconfig.json
|
||||
"projectDomain": "{{module.projectDomain}}"
|
||||
},
|
||||
"release": {
|
||||
"accessLevel": "{{module.npmAccessLevel}}"
|
||||
"targets": {
|
||||
"npm": {
|
||||
"accessLevel": "{{module.npmAccessLevel}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@ship.zone/szci": {
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
"npmPackagename": "{{module.npmPackagename}}",
|
||||
"license": "{{module.license}}",
|
||||
"projectDomain": "{{module.projectDomain}}"
|
||||
},
|
||||
"release": {
|
||||
"targets": {
|
||||
"npm": {
|
||||
"registries": ["{{private.npmRegistryUrl}}"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@ship.zone/szci": {
|
||||
@@ -18,7 +25,6 @@
|
||||
},
|
||||
"dockerBuildargEnvMap": {
|
||||
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
|
||||
},
|
||||
"npmRegistryUrl": "{{private.npmRegistryUrl}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## Pending
|
||||
|
||||
### Features
|
||||
|
||||
- Split `gitzone commit` and `gitzone release` into commit workflow and target-based release configuration.
|
||||
- Add pending changelog handling so commits accumulate unreleased notes and releases move them into version sections.
|
||||
- Add first-class release targets for git, npm, and Docker publishing.
|
||||
|
||||
## 2026-04-30 - 2.14.3 - fix(test)
|
||||
move test workspace into .nogit and add bundled fixture project files
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2015 Task Venture Capital GmbH (hello@lossless.com)
|
||||
Copyright (c) 2015 Task Venture Capital GmbH (hello@task.vc)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -16,4 +16,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
SOFTWARE.
|
||||
|
||||
+15
-17
@@ -38,7 +38,7 @@ The format module is responsible for project standardization:
|
||||
2. **copy** - File copying with glob patterns (fully implemented)
|
||||
3. **gitignore** - Creates/updates .gitignore from templates
|
||||
4. **license** - Checks dependency licenses for compatibility
|
||||
5. **npmextra** - Manages project metadata and configuration
|
||||
5. **smartconfig** - Manages project metadata and configuration
|
||||
6. **packagejson** - Formats and updates package.json
|
||||
7. **prettier** - Applies code formatting with batching
|
||||
8. **readme** - Ensures readme files exist
|
||||
@@ -89,28 +89,26 @@ The format module is responsible for project standardization:
|
||||
5. **Performance Optimizations**: Parallel execution and caching
|
||||
6. **Reporting**: Diff views, statistics, verbose logging
|
||||
7. **Architecture**: Clean separation of concerns with new classes
|
||||
8. **Unified Version Bumping**: Self-managed version updates eliminating npm warning pollution in deno.json
|
||||
8. **Split Commit/Release Workflows**: `commit` creates source commits; `release` owns versioning, tags, and artifact publishing
|
||||
|
||||
### Version Bumping Refactor (Latest)
|
||||
### Commit/Release Workflow Refactor (Latest)
|
||||
|
||||
The commit module's version bumping has been refactored to eliminate npm command dependencies:
|
||||
The commit module no longer bumps versions, creates tags, or publishes packages. Release work now belongs to `gitzone release`:
|
||||
|
||||
**Changes:**
|
||||
|
||||
- Removed `bumpNpmVersion()` - was causing npm warnings to pollute deno.json
|
||||
- Removed `syncVersionToDenoJson()` - no longer needed with unified approach
|
||||
- Removed separate `bumpDenoVersion()` - replaced by unified implementation
|
||||
- Added `readCurrentVersion()` helper - reads from either package.json or deno.json
|
||||
- Added `updateVersionFile()` helper - updates JSON files directly
|
||||
- Unified `bumpProjectVersion()` - handles npm/deno/both with single clean code path
|
||||
- `gitzone commit` analyzes changes, updates `changelog.md` `Pending`, commits, and optionally pushes.
|
||||
- `gitzone release` reads `Pending`, bumps versions, moves changelog entries into a version section, tags, pushes, and publishes configured artifacts.
|
||||
- Commit workflow steps are configured in `.smartconfig.json` under `@git.zone/cli.commit.steps`.
|
||||
- Smartconfig schema versioning lives at `@git.zone/cli.schemaVersion`; run `gitzone config migrate <version>` for targeted migrations.
|
||||
- Release publishing is target-based under `@git.zone/cli.release.targets`.
|
||||
- NPM registries only live under `@git.zone/cli.release.targets.npm.registries`.
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- No npm warning pollution in version fields
|
||||
- Full control over version bumping process
|
||||
- Simpler git history (no amending, no force-tagging)
|
||||
- Same code path for all project types
|
||||
- Reuses existing `calculateNewVersion()` function
|
||||
- Commit is safer and has no publishing side effects.
|
||||
- Multiple source commits can accumulate into one release via `Pending`.
|
||||
- Per-artifact release results can distinguish published, already-published, skipped, and failed targets.
|
||||
|
||||
### Auto-Accept Flag for Commits
|
||||
|
||||
@@ -219,7 +217,7 @@ gitzone format --detailed
|
||||
# Inspect config for agents and scripts
|
||||
gitzone config show --json
|
||||
gitzone config set cli.output json
|
||||
gitzone config get release.accessLevel
|
||||
gitzone config get release.targets.npm.accessLevel
|
||||
```
|
||||
|
||||
## Common Issues (Now Resolved)
|
||||
@@ -291,7 +289,7 @@ All sync methods must become async. Functions that were previously synchronous (
|
||||
**Affected Modules:**
|
||||
|
||||
- ts/mod_format/\* (largest area - 15+ files)
|
||||
- ts/mod_commit/\* (version bumping)
|
||||
- ts/mod_commit/\* and ts/mod_release/\* (commit/release workflows)
|
||||
- ts/mod_services/\* (configuration management)
|
||||
- ts/mod_meta/\* (meta repository management)
|
||||
- ts/mod_standard/\* (template listing)
|
||||
|
||||
@@ -1,101 +1,264 @@
|
||||
# @git.zone/cli 🚀
|
||||
|
||||
**The ultimate CLI toolbelt for modern TypeScript development workflows**
|
||||
`@git.zone/cli` is the development workflow CLI behind the `gitzone` and `gzone` commands. It helps TypeScript-heavy teams keep projects tidy, create semantic source commits, manage local Docker-backed services, scaffold new modules, and release software through explicit, target-based release configuration.
|
||||
|
||||
[](https://www.npmjs.com/package/@git.zone/cli)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
## 🎯 What is gitzone?
|
||||
|
||||
gitzone is a powerful command-line interface that supercharges your development workflow with automated project management, intelligent code formatting, seamless version control, and development service orchestration. Whether you're bootstrapping a new TypeScript project, maintaining code quality, managing complex multi-repository setups, or spinning up local development databases, gitzone has got you covered.
|
||||
It is opinionated where that saves time: source commits and releases are separate, changelog entries flow through a standard `Pending` section, project config lives in `.smartconfig.json`, and release targets make side effects visible before they happen.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## 🏃♂️ Quick Start
|
||||
|
||||
### Installation
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# Install globally via pnpm (recommended)
|
||||
pnpm add -g @git.zone/cli
|
||||
|
||||
# Or with npm
|
||||
npm install -g @git.zone/cli
|
||||
```
|
||||
|
||||
Once installed, you can use either `gitzone` or the shorter `gzone` command from anywhere in your terminal.
|
||||
|
||||
### Your First Commands
|
||||
After installation, both binaries point to the same CLI:
|
||||
|
||||
```bash
|
||||
# Create a new TypeScript npm package
|
||||
gitzone template npm
|
||||
gitzone --help
|
||||
gzone --help
|
||||
```
|
||||
|
||||
# Format your entire codebase (dry-run by default)
|
||||
## The Big Idea
|
||||
|
||||
`gitzone commit` handles source history.
|
||||
|
||||
`gitzone release` handles release transactions.
|
||||
|
||||
That split is intentional. A commit should not unexpectedly publish npm packages, push Docker images, or trigger remote release pipelines. A release should clearly show which targets it will publish to.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Preview project standardization work
|
||||
gitzone format
|
||||
|
||||
# Apply formatting changes
|
||||
gitzone format --write
|
||||
|
||||
# Start local MongoDB and MinIO services
|
||||
gitzone services start
|
||||
|
||||
# Create a semantic commit with AI-powered suggestions
|
||||
# Create a semantic source commit
|
||||
gitzone commit
|
||||
|
||||
# Preview the configured release transaction
|
||||
gitzone release --plan
|
||||
|
||||
# Release pending changelog entries to configured targets
|
||||
gitzone release
|
||||
```
|
||||
|
||||
## 🛠️ Core Features
|
||||
## Commands
|
||||
|
||||
### 🔀 Semantic Commits & Versioning
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `commit` | Analyze changes and create one semantic source commit |
|
||||
| `release` | Turn pending changelog entries into a versioned release and publish targets |
|
||||
| `format` | Plan or apply project formatting and standardization |
|
||||
| `config` | Inspect, update, and migrate `.smartconfig.json` |
|
||||
| `services` | Manage local MongoDB, MinIO, and Elasticsearch containers |
|
||||
| `template` | Scaffold projects from built-in templates |
|
||||
| `meta` | Manage multi-repository workspaces |
|
||||
| `open` | Open repository assets like CI pages |
|
||||
| `docker` | Run Docker maintenance tasks |
|
||||
| `deprecate` | Deprecate npm packages across registries |
|
||||
| `start` | Prepare an existing project for local work |
|
||||
| `helpers` | Run small helper utilities |
|
||||
|
||||
Create standardized commits with AI-powered suggestions that automatically handle versioning:
|
||||
Global flags include `--help`, `--json`, `--plain`, `--agent`, `--no-interactive`, and `--no-check-updates`.
|
||||
|
||||
## Commit Workflow
|
||||
|
||||
`gitzone commit` creates one semantic source commit. It does not bump versions, create tags, publish packages, or push Docker images.
|
||||
|
||||
```bash
|
||||
# Interactive commit with AI recommendations
|
||||
# Interactive semantic commit
|
||||
gitzone commit
|
||||
|
||||
# Read-only recommendation for agents and scripts
|
||||
# Read-only AI recommendation
|
||||
gitzone commit recommend --json
|
||||
|
||||
# Auto-accept AI recommendations (skipped for BREAKING CHANGEs)
|
||||
# Auto-accept safe recommendations
|
||||
gitzone commit -y
|
||||
|
||||
# Auto-accept, push, build, and release
|
||||
gitzone commit -ypbr
|
||||
# Auto-accept, test, build, and push
|
||||
gitzone commit -ytbp
|
||||
|
||||
# Show the resolved workflow without mutating anything
|
||||
gitzone commit --plan
|
||||
```
|
||||
|
||||
**Flags:**
|
||||
The commit flow:
|
||||
|
||||
| Flag | Long Form | Description |
|
||||
| ---- | ----------- | ---------------------------------------- |
|
||||
| `-y` | `--yes` | Auto-accept AI recommendations |
|
||||
| `-p` | `--push` | Push to remote after commit |
|
||||
| `-t` | `--test` | Run tests before committing |
|
||||
| `-b` | `--build` | Build after commit, verify clean tree |
|
||||
| `-r` | `--release` | Publish to configured npm registries |
|
||||
| | `--format` | Run format before committing |
|
||||
| | `--json` | Emit JSON for `gitzone commit recommend` |
|
||||
1. Analyze the working tree.
|
||||
2. Suggest commit type, scope, and message.
|
||||
3. Write a human-readable entry into `changelog.md` under `## Pending`.
|
||||
4. Stage and create one semantic source commit.
|
||||
5. Optionally run formatting, tests, build, and push based on flags or config.
|
||||
|
||||
**Workflow steps:**
|
||||
Commit flags:
|
||||
|
||||
1. 🤖 **AI-powered analysis** — analyzes your changes and suggests commit type, scope, and message
|
||||
2. 📝 Interactive commit message builder (type: `fix`/`feat`/`BREAKING CHANGE`, scope, description)
|
||||
3. 📜 Automatic changelog generation
|
||||
4. 🏷️ Automatic version bumping (major/minor/patch) with git tag creation
|
||||
5. 🔨 Optional build & verification
|
||||
6. 🚀 Optional push to origin
|
||||
7. 📦 Optional publish to npm registries
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `-y`, `--yes` | Auto-accept safe recommendations |
|
||||
| `-t`, `--test` | Add test step |
|
||||
| `-b`, `--build` | Add build step |
|
||||
| `-p`, `--push` | Push after the source commit |
|
||||
| `-f`, `--format` | Run `gitzone format --write` before commit |
|
||||
| `--plan` | Show resolved workflow only |
|
||||
|
||||
Supports both npm (`package.json`) and Deno (`deno.json`) projects, including dual-type projects.
|
||||
`-r` is intentionally not part of commit anymore. Use `gitzone release`.
|
||||
|
||||
### 🎨 Intelligent Code Formatting
|
||||
## Release Workflow
|
||||
|
||||
Automatically format and standardize your entire codebase. **Dry-run by default** — nothing changes until you explicitly use `--write`:
|
||||
`gitzone release` performs the release core once, then publishes to configured targets.
|
||||
|
||||
The release core is not configurable plumbing. It always follows the same professional release transaction:
|
||||
|
||||
1. Run configured preflight checks.
|
||||
2. Read `changelog.md` `## Pending` entries.
|
||||
3. Infer or accept a semver bump.
|
||||
4. Update version files and baked commit info.
|
||||
5. Move pending changelog entries into the new version section.
|
||||
6. Create the local release commit.
|
||||
7. Create the local release tag.
|
||||
|
||||
Targets decide what happens after that:
|
||||
|
||||
| Target | What it does |
|
||||
| --- | --- |
|
||||
| `git` | Pushes the release commit and tags, often triggering remote CI release builds |
|
||||
| `npm` | Publishes the package to configured npm registries |
|
||||
| `docker` | Builds and pushes configured Docker images |
|
||||
|
||||
```bash
|
||||
# Preview what would change (default behavior)
|
||||
# Preview the resolved release plan
|
||||
gitzone release --plan
|
||||
|
||||
# Release to configured targets
|
||||
gitzone release
|
||||
|
||||
# Release only to npm
|
||||
gitzone release --target npm
|
||||
|
||||
# Release only to git and Docker
|
||||
gitzone release --target git,docker
|
||||
|
||||
# Skip package/container publishing and keep only git target
|
||||
gitzone release --no-publish
|
||||
|
||||
# Override inferred semver level
|
||||
gitzone release --minor
|
||||
```
|
||||
|
||||
Release flags:
|
||||
|
||||
| Flag | Meaning |
|
||||
| --- | --- |
|
||||
| `-y`, `--yes` | Run without interactive confirmation |
|
||||
| `-t`, `--test` | Enable preflight tests |
|
||||
| `-b`, `--build` | Enable preflight build |
|
||||
| `-p`, `--push` | Enable the `git` target |
|
||||
| `--target <csv>` | Use only selected targets, e.g. `git,npm` |
|
||||
| `--npm` | Enable the `npm` target |
|
||||
| `--docker` | Enable the `docker` target |
|
||||
| `--no-publish` | Keep release core and `git` target only |
|
||||
| `--no-build` | Disable preflight build for this run |
|
||||
| `--major`, `--minor`, `--patch` | Override inferred semver level |
|
||||
| `--plan` | Show resolved workflow only |
|
||||
|
||||
## Standard Changelog
|
||||
|
||||
The changelog is convention-based and intentionally not configured.
|
||||
|
||||
`gitzone commit` appends entries to:
|
||||
|
||||
```markdown
|
||||
## Pending
|
||||
```
|
||||
|
||||
`gitzone release` moves those pending entries into a dated version section:
|
||||
|
||||
```markdown
|
||||
## 2026-05-10 - 2.15.0
|
||||
```
|
||||
|
||||
The standard buckets are `Breaking Changes`, `Features`, `Fixes`, `Documentation`, and `Maintenance`.
|
||||
|
||||
## Configuration
|
||||
|
||||
All CLI config lives under `@git.zone/cli` in `.smartconfig.json`.
|
||||
|
||||
```json
|
||||
{
|
||||
"@git.zone/cli": {
|
||||
"schemaVersion": 2,
|
||||
"projectType": "npm",
|
||||
"commit": {
|
||||
"confirmation": "prompt",
|
||||
"steps": ["analyze", "test", "build", "changelog", "commit", "push"]
|
||||
},
|
||||
"release": {
|
||||
"confirmation": "prompt",
|
||||
"preflight": {
|
||||
"requireCleanTree": true,
|
||||
"test": false,
|
||||
"build": true
|
||||
},
|
||||
"targets": {
|
||||
"git": {
|
||||
"enabled": true,
|
||||
"remote": "origin",
|
||||
"pushBranch": true,
|
||||
"pushTags": true
|
||||
},
|
||||
"npm": {
|
||||
"enabled": true,
|
||||
"registries": ["https://registry.npmjs.org"],
|
||||
"accessLevel": "public",
|
||||
"alreadyPublished": "success"
|
||||
},
|
||||
"docker": {
|
||||
"enabled": false,
|
||||
"images": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
NPM registries belong only here:
|
||||
|
||||
```text
|
||||
@git.zone/cli.release.targets.npm.registries
|
||||
```
|
||||
|
||||
Useful config commands:
|
||||
|
||||
```bash
|
||||
# Show current @git.zone/cli config
|
||||
gitzone config show --json
|
||||
|
||||
# Read the npm release target registries
|
||||
gitzone config get release.targets.npm.registries
|
||||
|
||||
# Add an npm release target registry
|
||||
gitzone config add https://registry.npmjs.org
|
||||
|
||||
# Set npm target access level
|
||||
gitzone config access public
|
||||
|
||||
# Run schema migration to v2
|
||||
gitzone config migrate 2
|
||||
```
|
||||
|
||||
## Formatting
|
||||
|
||||
`gitzone format` is dry-run by default. That makes it safe to run in any repo.
|
||||
|
||||
```bash
|
||||
# Preview changes
|
||||
gitzone format
|
||||
|
||||
# Emit a machine-readable plan
|
||||
@@ -104,471 +267,126 @@ gitzone format plan --json
|
||||
# Apply changes
|
||||
gitzone format --write
|
||||
|
||||
# Auto-approve without prompts
|
||||
gitzone format --yes --write
|
||||
|
||||
# Show detailed diffs
|
||||
gitzone format --diff
|
||||
|
||||
# Enable verbose logging
|
||||
gitzone format --verbose
|
||||
# Apply without prompt
|
||||
gitzone format --write --yes
|
||||
```
|
||||
|
||||
**Flags:**
|
||||
Formatters include cleanup, smartconfig normalization, dependency license checks, package metadata normalization, template updates, `.gitignore`, TypeScript config, Prettier, README existence checks, and configured copy operations.
|
||||
|
||||
| Flag | Description |
|
||||
| -------------------- | --------------------------------------------- |
|
||||
| `--write` / `-w` | Apply changes (default is dry-run) |
|
||||
| `--yes` | Auto-approve without interactive confirmation |
|
||||
| `--plan-only` | Only show what would be done |
|
||||
| `--save-plan <file>` | Save the format plan to a file |
|
||||
| `--from-plan <file>` | Load and execute a saved plan |
|
||||
| `--detailed` | Show detailed stats and save report |
|
||||
| `--verbose` | Enable verbose logging |
|
||||
| `--diff` | Show file diffs |
|
||||
| `--json` | Emit a read-only format plan as JSON |
|
||||
## Development Services
|
||||
|
||||
**Formatters (executed in order):**
|
||||
`gitzone services` manages local Docker-backed services for development projects.
|
||||
|
||||
1. 🧹 **Cleanup** — removes obsolete files (yarn.lock, package-lock.json, tslint.json, etc.)
|
||||
2. ⚙️ **Smartconfig** — formats and standardizes `.smartconfig.json`
|
||||
3. 📜 **License** — ensures proper licensing and checks dependency licenses
|
||||
4. 📦 **Package.json** — standardizes package configuration
|
||||
5. 📋 **Templates** — applies project template updates
|
||||
6. 🙈 **Gitignore** — updates repository ignore rules
|
||||
7. 🔧 **Tsconfig** — optimizes TypeScript configuration
|
||||
8. ✨ **Prettier** — applies code formatting
|
||||
9. 📖 **Readme** — ensures readme files exist
|
||||
10. 📂 **Copy** — copies configured files
|
||||
Supported services:
|
||||
|
||||
### 🐳 Development Services Management
|
||||
|
||||
Effortlessly manage local development services (MongoDB, MinIO S3, Elasticsearch) with Docker:
|
||||
| Service | Aliases |
|
||||
| --- | --- |
|
||||
| MongoDB | `mongo`, `mongodb` |
|
||||
| MinIO | `minio`, `s3` |
|
||||
| Elasticsearch | `elasticsearch`, `es` |
|
||||
|
||||
```bash
|
||||
gitzone services [command]
|
||||
```
|
||||
|
||||
**Commands:**
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------ | ------------------------------------------------------ |
|
||||
| `start [service]` | Start services (`mongo`\|`s3`\|`elasticsearch`\|`all`) |
|
||||
| `stop [service]` | Stop services |
|
||||
| `restart [service]` | Restart services |
|
||||
| `status` | Show current service status |
|
||||
| `config` | Display configuration details |
|
||||
| `set <csv>` | Set enabled services without prompts |
|
||||
| `enable <service...>` | Enable one or more services |
|
||||
| `disable <service...>` | Disable one or more services |
|
||||
| `compass` | Get MongoDB Compass connection string with network IP |
|
||||
| `logs [service] [lines]` | View service logs (default: 20 lines) |
|
||||
| `reconfigure` | Reassign ports and restart all services |
|
||||
| `remove` | Remove containers (preserves data) |
|
||||
| `clean` | Remove containers AND data (⚠️ destructive) |
|
||||
|
||||
**Service aliases:**
|
||||
|
||||
- `mongo` / `mongodb` — MongoDB
|
||||
- `minio` / `s3` — MinIO (S3-compatible storage)
|
||||
- `elasticsearch` / `es` — Elasticsearch
|
||||
- `all` — All services (default)
|
||||
|
||||
**Key features:**
|
||||
|
||||
- 🎲 **Smart port assignment** — automatically assigns random ports (20000–30000) to avoid conflicts
|
||||
- 📦 **Project isolation** — each project gets its own containers with unique names
|
||||
- 💾 **Data persistence** — data stored in `.nogit/` survives container restarts
|
||||
- 🔗 **MongoDB Compass support** — instantly get connection strings for GUI access
|
||||
- 🌐 **Network IP detection** — detects your local network IP for remote connections
|
||||
- ⚙️ **Auto-configuration** — creates `.nogit/env.json` with smart defaults
|
||||
|
||||
**Global operations (`-g` flag):**
|
||||
|
||||
```bash
|
||||
# List all registered projects
|
||||
gitzone services list -g
|
||||
|
||||
# Show status across all projects
|
||||
gitzone services status -g
|
||||
|
||||
# Stop all containers across all projects
|
||||
gitzone services stop -g
|
||||
|
||||
# Remove stale registry entries
|
||||
gitzone services cleanup -g
|
||||
```
|
||||
|
||||
**Example workflow:**
|
||||
|
||||
```bash
|
||||
# Start all services for your project
|
||||
# Start configured services
|
||||
gitzone services start
|
||||
|
||||
# Configure enabled services without prompts
|
||||
# Enable specific services non-interactively
|
||||
gitzone services set mongodb,minio
|
||||
|
||||
# Check what's running
|
||||
# Check status
|
||||
gitzone services status
|
||||
|
||||
# Get MongoDB Compass connection string
|
||||
# Print MongoDB Compass connection string
|
||||
gitzone services compass
|
||||
# Output: mongodb://defaultadmin:defaultpass@192.168.1.100:27018/myproject?authSource=admin
|
||||
|
||||
# View MongoDB logs
|
||||
# Show logs
|
||||
gitzone services logs mongo 50
|
||||
|
||||
# Stop services when done
|
||||
# Stop containers but keep data
|
||||
gitzone services stop
|
||||
|
||||
# Remove containers and data
|
||||
gitzone services clean
|
||||
```
|
||||
|
||||
### ⚙️ Release & Commit Configuration
|
||||
Service config is stored in `.nogit/env.json`. Data is stored below `.nogit/`, so it stays out of Git.
|
||||
|
||||
Manage release registries and commit settings:
|
||||
## Templates
|
||||
|
||||
Start new projects with built-in scaffolds:
|
||||
|
||||
```bash
|
||||
gitzone config [subcommand]
|
||||
gitzone template npm
|
||||
gitzone template service
|
||||
gitzone template website
|
||||
gitzone template wcc
|
||||
```
|
||||
|
||||
| Command | Description |
|
||||
| ---------------------------------- | ---------------------------------------------------------- |
|
||||
| `show` | Display current release config (registries, access level) |
|
||||
| `get <path>` | Read a single value from `@git.zone/cli` |
|
||||
| `set <path> <value>` | Write a single value to `@git.zone/cli` |
|
||||
| `unset <path>` | Remove a single value from `@git.zone/cli` |
|
||||
| `add [url]` | Add a registry URL (default: `https://registry.npmjs.org`) |
|
||||
| `remove [url]` | Remove a registry URL (interactive selection if no URL) |
|
||||
| `clear` | Clear all registries (with confirmation) |
|
||||
| `access [public\|private]` | Set npm access level for publishing |
|
||||
| `commit alwaysTest [true\|false]` | Always run tests before commit |
|
||||
| `commit alwaysBuild [true\|false]` | Always build after commit |
|
||||
| `services` | Configure which services are enabled |
|
||||
Templates are rendered through SmartScaf and then can be normalized with `gitzone format`.
|
||||
|
||||
Configuration is stored in `.smartconfig.json` under the `@git.zone/cli` key.
|
||||
## Meta Repositories
|
||||
|
||||
### 📦 Project Templates
|
||||
|
||||
Instantly scaffold production-ready projects with best practices built-in:
|
||||
Use `gitzone meta` when one workspace coordinates multiple repositories.
|
||||
|
||||
```bash
|
||||
gitzone template [template-name]
|
||||
```
|
||||
|
||||
**Interactive templates:**
|
||||
|
||||
- **`npm`** — TypeScript npm package with testing, CI/CD, and full tooling
|
||||
- **`service`** — Microservice architecture with Docker support
|
||||
- **`website`** — Modern web application with LitElement and service workers
|
||||
- **`wcc`** — Web Component Collection for reusable UI components
|
||||
|
||||
Each template comes pre-configured with:
|
||||
|
||||
- ✅ TypeScript with modern configurations
|
||||
- ✅ Automated testing setup with `@git.zone/tstest`
|
||||
- ✅ CI/CD pipelines (GitLab/GitHub)
|
||||
- ✅ Code formatting and linting
|
||||
- ✅ Documentation structure
|
||||
|
||||
### 🏗️ Meta Repository Management
|
||||
|
||||
Manage multiple related repositories as a cohesive unit:
|
||||
|
||||
```bash
|
||||
# Initialize a meta repository
|
||||
gitzone meta init
|
||||
|
||||
# Add a sub-project
|
||||
gitzone meta add [name] [git-url]
|
||||
|
||||
# Update all sub-projects (clone missing, clean superfluous)
|
||||
gitzone meta add frontend https://example.com/org/frontend.git
|
||||
gitzone meta update
|
||||
|
||||
# Remove a sub-project
|
||||
gitzone meta remove [name]
|
||||
gitzone meta remove frontend
|
||||
```
|
||||
|
||||
### 🐳 Docker Management
|
||||
|
||||
Streamline your Docker workflow:
|
||||
## Other Utilities
|
||||
|
||||
```bash
|
||||
# Clean up all Docker resources (containers, images, volumes, networks)
|
||||
# Docker cleanup
|
||||
gitzone docker prune
|
||||
```
|
||||
|
||||
### 🔗 Quick CI/CD Access
|
||||
|
||||
Jump directly to your CI/CD configurations:
|
||||
|
||||
```bash
|
||||
# Open CI/CD settings
|
||||
# Open GitLab CI settings or pipelines for the current repo
|
||||
gitzone open ci
|
||||
|
||||
# Open pipelines view
|
||||
gitzone open pipelines
|
||||
```
|
||||
|
||||
Works with GitLab repositories to provide instant access to your deployment configurations.
|
||||
|
||||
### 📝 Package Deprecation
|
||||
|
||||
Smoothly transition users from old to new packages:
|
||||
|
||||
```bash
|
||||
# Deprecate an old npm package interactively
|
||||
gitzone deprecate
|
||||
```
|
||||
|
||||
Interactive wizard that prompts for registry URLs, old package name, and new package name — then runs `npm deprecate` across all specified registries.
|
||||
|
||||
### 🚦 Project Initialization
|
||||
|
||||
Prepare existing projects for development:
|
||||
|
||||
```bash
|
||||
# Prepare a project for local work
|
||||
gitzone start
|
||||
```
|
||||
|
||||
Automatically checks out master, pulls latest changes, and installs dependencies.
|
||||
|
||||
### 🔧 Helper Utilities
|
||||
|
||||
```bash
|
||||
# Generate a unique short ID
|
||||
# Generate a short unique ID
|
||||
gitzone helpers shortid
|
||||
```
|
||||
|
||||
## 📋 Configuration
|
||||
## Troubleshooting
|
||||
|
||||
### .smartconfig.json
|
||||
|
||||
Customize gitzone behavior through `.smartconfig.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"@git.zone/cli": {
|
||||
"projectType": "npm",
|
||||
"cli": {
|
||||
"interactive": true,
|
||||
"output": "human",
|
||||
"checkUpdates": true
|
||||
},
|
||||
"release": {
|
||||
"registries": ["https://registry.npmjs.org"],
|
||||
"accessLevel": "public"
|
||||
},
|
||||
"commit": {
|
||||
"alwaysTest": false,
|
||||
"alwaysBuild": false
|
||||
},
|
||||
"format": {
|
||||
"interactive": true,
|
||||
"showStats": true,
|
||||
"modules": {
|
||||
"skip": ["prettier"],
|
||||
"only": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `CI` — Detect CI environment for automated workflows
|
||||
- `DEBUG` — Enable debug output
|
||||
- `GITZONE_FORMAT_PARALLEL` — Control parallel formatting
|
||||
|
||||
## 🎯 Common Workflows
|
||||
|
||||
### Full-Stack Development Cycle
|
||||
Format only previews changes:
|
||||
|
||||
```bash
|
||||
# 1. Start fresh
|
||||
gitzone start
|
||||
|
||||
# 2. Spin up databases and services
|
||||
gitzone services start
|
||||
|
||||
# 3. Make changes
|
||||
# ... your development work ...
|
||||
|
||||
# 4. Check service logs if needed
|
||||
gitzone services logs mongo
|
||||
|
||||
# 5. Preview format changes, then apply
|
||||
gitzone format
|
||||
gitzone format --write
|
||||
```
|
||||
|
||||
# 6. Commit with semantic versioning
|
||||
Release says there is nothing to release:
|
||||
|
||||
```bash
|
||||
# Make sure commits have populated the Pending changelog section
|
||||
gitzone commit
|
||||
|
||||
# 7. Stop services when done
|
||||
gitzone services stop
|
||||
```
|
||||
|
||||
### Automated CI/CD Commit
|
||||
|
||||
```bash
|
||||
# Auto-accept, test, build, push, and release in one command
|
||||
gitzone commit -ytbpr
|
||||
```
|
||||
|
||||
### Agent-Friendly Inspection
|
||||
|
||||
```bash
|
||||
# Top-level machine-readable help
|
||||
gitzone help config --json
|
||||
|
||||
# Read-only commit recommendation
|
||||
gitzone commit recommend --json
|
||||
|
||||
# Read-only format plan
|
||||
gitzone format plan --json
|
||||
|
||||
# Read or change config without prompts
|
||||
gitzone config get release.accessLevel
|
||||
gitzone config set cli.interactive false
|
||||
```
|
||||
|
||||
### Multi-Repository Management
|
||||
|
||||
```bash
|
||||
# 1. Set up meta repository
|
||||
gitzone meta init
|
||||
|
||||
# 2. Add all related projects
|
||||
gitzone meta add frontend https://github.com/org/frontend.git
|
||||
gitzone meta add backend https://github.com/org/backend.git
|
||||
gitzone meta add shared https://github.com/org/shared.git
|
||||
|
||||
# 3. Synchronize updates
|
||||
gitzone meta update
|
||||
```
|
||||
|
||||
### Safe Formatting with Plan Review
|
||||
|
||||
```bash
|
||||
# 1. Preview changes (default)
|
||||
gitzone format
|
||||
|
||||
# 2. Save plan for review
|
||||
gitzone format --save-plan format-changes.json
|
||||
|
||||
# 3. Apply from saved plan
|
||||
gitzone format --from-plan format-changes.json --write
|
||||
```
|
||||
|
||||
### Database-Driven Development
|
||||
|
||||
```bash
|
||||
# 1. Start MongoDB, MinIO, and Elasticsearch
|
||||
gitzone services start
|
||||
|
||||
# 2. Get connection details
|
||||
gitzone services config
|
||||
|
||||
# 3. Connect with MongoDB Compass
|
||||
gitzone services compass
|
||||
|
||||
# 4. Monitor services
|
||||
gitzone services status
|
||||
|
||||
# 5. Clean everything when done
|
||||
gitzone services clean # ⚠️ Warning: deletes data
|
||||
```
|
||||
|
||||
## 🔌 Integrations
|
||||
|
||||
### CI/CD Platforms
|
||||
|
||||
- **GitLab CI** — full pipeline support with templates
|
||||
- **GitHub Actions** — automated workflows
|
||||
- **Docker** — container-based deployments
|
||||
|
||||
### Development Tools
|
||||
|
||||
- **TypeScript** — first-class support
|
||||
- **Prettier** — code formatting
|
||||
- **pnpm** — package management
|
||||
- **MongoDB** — local database service
|
||||
- **MinIO** — S3-compatible object storage
|
||||
- **Elasticsearch** — search and analytics
|
||||
- **MongoDB Compass** — database GUI integration
|
||||
|
||||
### Version Control
|
||||
|
||||
- **Git** — deep integration
|
||||
- **Semantic Versioning** — automatic version bumping
|
||||
- **Conventional Commits** — standardized commit messages
|
||||
- **AI-Powered Analysis** — intelligent commit suggestions via `@git.zone/tsdoc`
|
||||
|
||||
## 💡 Pro Tips
|
||||
|
||||
1. **Use aliases**: Add `alias gz='gitzone'` to your shell profile
|
||||
2. **Combine flags**: `gitzone commit -ypbr` for the full auto workflow
|
||||
3. **Leverage templates**: Start projects right with proven structures
|
||||
4. **Enable caching**: Dramatically speeds up formatting operations
|
||||
5. **Save format plans**: Review changes before applying
|
||||
6. **Port management**: Let services auto-assign ports to avoid conflicts
|
||||
7. **Use MongoDB Compass**: `gitzone services compass` for visual DB management
|
||||
8. **Global service management**: `gitzone services status -g` to see all projects' services at once
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Format Command Shows "Cancelled"
|
||||
|
||||
- Check your `npmextra.json` configuration
|
||||
- Try with `--yes --write` flags
|
||||
- Use `--verbose` for detailed output
|
||||
|
||||
### Docker Commands Fail
|
||||
|
||||
Ensure Docker daemon is running:
|
||||
Docker services fail to start:
|
||||
|
||||
```bash
|
||||
docker info
|
||||
```
|
||||
|
||||
### Services Won't Start
|
||||
|
||||
```bash
|
||||
# Services auto-assign ports, but you can check the config
|
||||
cat .nogit/env.json
|
||||
|
||||
# Verify Docker is running
|
||||
docker ps
|
||||
|
||||
# Reassign ports if there are conflicts
|
||||
gitzone services status
|
||||
gitzone services reconfigure
|
||||
```
|
||||
|
||||
### Template Creation Issues
|
||||
|
||||
Verify pnpm/npm is properly configured:
|
||||
Config looks outdated:
|
||||
|
||||
```bash
|
||||
npm config get registry
|
||||
gitzone config migrate 2
|
||||
gitzone config show --json
|
||||
```
|
||||
|
||||
### MongoDB Connection Issues
|
||||
|
||||
- Ensure services are running: `gitzone services status`
|
||||
- Check firewall settings for the assigned ports
|
||||
- Use `gitzone services compass` for the correct connection string
|
||||
|
||||
## 📈 Performance
|
||||
|
||||
gitzone is optimized for speed:
|
||||
|
||||
- ⚡ **Parallel processing** for format operations
|
||||
- 🧠 **Smart caching** to avoid redundant work
|
||||
- 📊 **Incremental updates** for meta repositories
|
||||
- 🐳 **Isolated services** prevent resource conflicts
|
||||
- 🎲 **Auto port assignment** eliminates manual configuration
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./license) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
@@ -51,6 +51,14 @@ export let run = async () => {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import * as plugins from "./plugins.js";
|
||||
|
||||
export type TChangelogBucket =
|
||||
| "Breaking Changes"
|
||||
| "Features"
|
||||
| "Fixes"
|
||||
| "Documentation"
|
||||
| "Maintenance";
|
||||
|
||||
export interface IChangelogEntry {
|
||||
type: string;
|
||||
scope: string;
|
||||
message: string;
|
||||
details?: string[];
|
||||
}
|
||||
|
||||
export interface IPendingChangelog {
|
||||
block: string;
|
||||
isEmpty: boolean;
|
||||
}
|
||||
|
||||
const bucketForCommitType = (commitType: string): TChangelogBucket => {
|
||||
switch (commitType) {
|
||||
case "BREAKING CHANGE":
|
||||
return "Breaking Changes";
|
||||
case "feat":
|
||||
return "Features";
|
||||
case "fix":
|
||||
return "Fixes";
|
||||
case "docs":
|
||||
return "Documentation";
|
||||
default:
|
||||
return "Maintenance";
|
||||
}
|
||||
};
|
||||
|
||||
const readChangelog = async (filePath: string): Promise<string> => {
|
||||
if (!(await plugins.smartfs.file(filePath).exists())) {
|
||||
return "# Changelog\n\n";
|
||||
}
|
||||
return (await plugins.smartfs.file(filePath).encoding("utf8").read()) as string;
|
||||
};
|
||||
|
||||
const writeChangelog = async (filePath: string, content: string): Promise<void> => {
|
||||
await plugins.smartfs.file(filePath).encoding("utf8").write(content.endsWith("\n") ? content : `${content}\n`);
|
||||
};
|
||||
|
||||
const findPendingSection = (
|
||||
content: string,
|
||||
sectionName: string,
|
||||
): { start: number; bodyStart: number; end: number } | null => {
|
||||
const headingRegex = new RegExp(`^##\\s+${sectionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
|
||||
const match = headingRegex.exec(content);
|
||||
if (!match || match.index === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bodyStart = match.index + match[0].length;
|
||||
const rest = content.slice(bodyStart);
|
||||
const nextHeadingMatch = /^##\s+/m.exec(rest);
|
||||
const end = nextHeadingMatch ? bodyStart + nextHeadingMatch.index : content.length;
|
||||
return { start: match.index, bodyStart, end };
|
||||
};
|
||||
|
||||
export const ensurePendingSection = async (
|
||||
filePath: string,
|
||||
sectionName = "Pending",
|
||||
): Promise<string> => {
|
||||
let content = await readChangelog(filePath);
|
||||
if (findPendingSection(content, sectionName)) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const pendingSection = `## ${sectionName}\n\n`;
|
||||
const titleMatch = /^#\s+.+$/m.exec(content);
|
||||
if (titleMatch && titleMatch.index !== undefined) {
|
||||
const insertAt = titleMatch.index + titleMatch[0].length;
|
||||
content = `${content.slice(0, insertAt)}\n\n${pendingSection}${content.slice(insertAt).replace(/^\n+/, "")}`;
|
||||
} else {
|
||||
content = `# Changelog\n\n${pendingSection}${content}`;
|
||||
}
|
||||
|
||||
await writeChangelog(filePath, content);
|
||||
return content;
|
||||
};
|
||||
|
||||
export const appendPendingChangelogEntry = async (
|
||||
filePath: string,
|
||||
sectionName: string,
|
||||
entry: IChangelogEntry,
|
||||
): Promise<void> => {
|
||||
let content = await ensurePendingSection(filePath, sectionName);
|
||||
const pendingSection = findPendingSection(content, sectionName)!;
|
||||
let pendingBody = content.slice(pendingSection.bodyStart, pendingSection.end);
|
||||
const bucket = bucketForCommitType(entry.type);
|
||||
const bucketHeading = `### ${bucket}`;
|
||||
|
||||
const entryLines = [`- ${entry.message}${entry.scope ? ` (${entry.scope})` : ""}`];
|
||||
for (const detail of entry.details || []) {
|
||||
entryLines.push(` - ${detail}`);
|
||||
}
|
||||
const renderedEntry = entryLines.join("\n");
|
||||
|
||||
const bucketRegex = new RegExp(`^###\\s+${bucket.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
|
||||
const bucketMatch = bucketRegex.exec(pendingBody);
|
||||
if (!bucketMatch || bucketMatch.index === undefined) {
|
||||
pendingBody = `${pendingBody.trimEnd()}\n\n${bucketHeading}\n\n${renderedEntry}\n`;
|
||||
} else {
|
||||
const bucketBodyStart = bucketMatch.index + bucketMatch[0].length;
|
||||
const afterBucket = pendingBody.slice(bucketBodyStart);
|
||||
const nextBucketMatch = /^###\s+/m.exec(afterBucket);
|
||||
const insertAt = nextBucketMatch ? bucketBodyStart + nextBucketMatch.index : pendingBody.length;
|
||||
const beforeInsert = pendingBody.slice(0, insertAt).trimEnd();
|
||||
const afterInsert = pendingBody.slice(insertAt).replace(/^\n+/, "");
|
||||
pendingBody = `${beforeInsert}\n${renderedEntry}\n\n${afterInsert}`;
|
||||
}
|
||||
|
||||
content = `${content.slice(0, pendingSection.bodyStart)}\n${pendingBody.trim()}\n\n${content.slice(pendingSection.end).replace(/^\n+/, "")}`;
|
||||
await writeChangelog(filePath, content);
|
||||
};
|
||||
|
||||
export const readPendingChangelog = async (
|
||||
filePath: string,
|
||||
sectionName = "Pending",
|
||||
): Promise<IPendingChangelog> => {
|
||||
const content = await ensurePendingSection(filePath, sectionName);
|
||||
const pendingSection = findPendingSection(content, sectionName)!;
|
||||
const block = content.slice(pendingSection.bodyStart, pendingSection.end).trim();
|
||||
return {
|
||||
block,
|
||||
isEmpty: block.length === 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const inferVersionTypeFromPending = (pendingBlock: string): "patch" | "minor" | "major" => {
|
||||
if (/^###\s+Breaking Changes\s*$/m.test(pendingBlock)) {
|
||||
return "major";
|
||||
}
|
||||
if (/^###\s+Features\s*$/m.test(pendingBlock)) {
|
||||
return "minor";
|
||||
}
|
||||
return "patch";
|
||||
};
|
||||
|
||||
export const movePendingToVersion = async (
|
||||
filePath: string,
|
||||
sectionName: string,
|
||||
versionHeading: string,
|
||||
version: string,
|
||||
dateString: string,
|
||||
): Promise<void> => {
|
||||
let content = await ensurePendingSection(filePath, sectionName);
|
||||
const pendingSection = findPendingSection(content, sectionName)!;
|
||||
const pendingBlock = content.slice(pendingSection.bodyStart, pendingSection.end).trim();
|
||||
if (!pendingBlock) {
|
||||
throw new Error("No pending changelog entries. Nothing to release.");
|
||||
}
|
||||
|
||||
const renderedHeading = versionHeading
|
||||
.replaceAll("{{version}}", version)
|
||||
.replaceAll("{{date}}", dateString);
|
||||
const nextContent = content.slice(pendingSection.end).replace(/^\n+/, "");
|
||||
content = `${content.slice(0, pendingSection.bodyStart)}\n\n${renderedHeading}\n\n${pendingBlock}\n\n${nextContent}`;
|
||||
await writeChangelog(filePath, content);
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
export const CURRENT_GITZONE_CLI_SCHEMA_VERSION = 2;
|
||||
|
||||
export interface ISmartconfigMigrationResult {
|
||||
migrated: boolean;
|
||||
fromVersion: number;
|
||||
toVersion: number;
|
||||
}
|
||||
|
||||
const CLI_NAMESPACE = "@git.zone/cli";
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, any> => {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
};
|
||||
|
||||
const ensureObject = (parent: Record<string, any>, key: string): Record<string, any> => {
|
||||
if (!isPlainObject(parent[key])) {
|
||||
parent[key] = {};
|
||||
}
|
||||
return parent[key];
|
||||
};
|
||||
|
||||
const migrateNamespaceKeys = (smartconfigJson: Record<string, any>): boolean => {
|
||||
let migrated = false;
|
||||
const migrations = [
|
||||
{ oldKey: "gitzone", newKey: CLI_NAMESPACE },
|
||||
{ oldKey: "tsdoc", newKey: "@git.zone/tsdoc" },
|
||||
{ oldKey: "npmdocker", newKey: "@git.zone/tsdocker" },
|
||||
{ oldKey: "npmci", newKey: "@ship.zone/szci" },
|
||||
{ oldKey: "szci", newKey: "@ship.zone/szci" },
|
||||
];
|
||||
|
||||
for (const { oldKey, newKey } of migrations) {
|
||||
if (!isPlainObject(smartconfigJson[oldKey])) {
|
||||
continue;
|
||||
}
|
||||
if (!isPlainObject(smartconfigJson[newKey])) {
|
||||
smartconfigJson[newKey] = smartconfigJson[oldKey];
|
||||
} else {
|
||||
smartconfigJson[newKey] = {
|
||||
...smartconfigJson[oldKey],
|
||||
...smartconfigJson[newKey],
|
||||
};
|
||||
}
|
||||
delete smartconfigJson[oldKey];
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
return migrated;
|
||||
};
|
||||
|
||||
const migrateToV2 = (smartconfigJson: Record<string, any>): boolean => {
|
||||
const cliConfig = ensureObject(smartconfigJson, CLI_NAMESPACE);
|
||||
const releaseConfig = ensureObject(cliConfig, "release");
|
||||
|
||||
let migrated = false;
|
||||
const targets = ensureObject(releaseConfig, "targets");
|
||||
const shipzoneConfig = smartconfigJson["@ship.zone/szci"];
|
||||
|
||||
if (isPlainObject(releaseConfig.git) && !isPlainObject(targets.git)) {
|
||||
targets.git = releaseConfig.git;
|
||||
delete releaseConfig.git;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (isPlainObject(releaseConfig.npm) && !isPlainObject(targets.npm)) {
|
||||
targets.npm = releaseConfig.npm;
|
||||
delete releaseConfig.npm;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (isPlainObject(releaseConfig.docker) && !isPlainObject(targets.docker)) {
|
||||
targets.docker = releaseConfig.docker;
|
||||
delete releaseConfig.docker;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (Array.isArray(releaseConfig.registries)) {
|
||||
const npmTarget = ensureObject(targets, "npm");
|
||||
if (!Array.isArray(npmTarget.registries)) {
|
||||
npmTarget.registries = releaseConfig.registries;
|
||||
}
|
||||
delete releaseConfig.registries;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (releaseConfig.accessLevel) {
|
||||
const npmTarget = ensureObject(targets, "npm");
|
||||
if (!npmTarget.accessLevel) {
|
||||
npmTarget.accessLevel = releaseConfig.accessLevel;
|
||||
}
|
||||
delete releaseConfig.accessLevel;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (isPlainObject(shipzoneConfig)) {
|
||||
if (shipzoneConfig.npmAccessLevel) {
|
||||
const npmTarget = ensureObject(targets, "npm");
|
||||
if (!npmTarget.accessLevel) {
|
||||
npmTarget.accessLevel = shipzoneConfig.npmAccessLevel;
|
||||
}
|
||||
delete shipzoneConfig.npmAccessLevel;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (shipzoneConfig.npmRegistryUrl) {
|
||||
const npmTarget = ensureObject(targets, "npm");
|
||||
const registry = normalizeRegistryUrl(shipzoneConfig.npmRegistryUrl);
|
||||
const registries = Array.isArray(npmTarget.registries) ? npmTarget.registries : [];
|
||||
if (!registries.includes(registry)) {
|
||||
registries.push(registry);
|
||||
}
|
||||
npmTarget.registries = registries;
|
||||
delete shipzoneConfig.npmRegistryUrl;
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(releaseConfig.steps)) {
|
||||
const steps = releaseConfig.steps as string[];
|
||||
const preflight = ensureObject(releaseConfig, "preflight");
|
||||
if (steps.includes("test") && preflight.test === undefined) {
|
||||
preflight.test = true;
|
||||
}
|
||||
if (steps.includes("build") && preflight.build === undefined) {
|
||||
preflight.build = true;
|
||||
}
|
||||
if (steps.includes("push")) {
|
||||
const gitTarget = ensureObject(targets, "git");
|
||||
if (gitTarget.enabled === undefined) {
|
||||
gitTarget.enabled = true;
|
||||
}
|
||||
}
|
||||
if (steps.includes("publishNpm")) {
|
||||
const npmTarget = ensureObject(targets, "npm");
|
||||
if (npmTarget.enabled === undefined) {
|
||||
npmTarget.enabled = true;
|
||||
}
|
||||
}
|
||||
if (steps.includes("publishDocker")) {
|
||||
const dockerTarget = ensureObject(targets, "docker");
|
||||
if (dockerTarget.enabled === undefined) {
|
||||
dockerTarget.enabled = true;
|
||||
}
|
||||
}
|
||||
delete releaseConfig.steps;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (releaseConfig.changelog) {
|
||||
delete releaseConfig.changelog;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
cliConfig.schemaVersion = 2;
|
||||
return migrated || true;
|
||||
};
|
||||
|
||||
const normalizeRegistryUrl = (url: string): string => {
|
||||
let normalizedUrl = url.trim();
|
||||
if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
|
||||
normalizedUrl = `https://${normalizedUrl}`;
|
||||
}
|
||||
return normalizedUrl.endsWith("/") ? normalizedUrl.slice(0, -1) : normalizedUrl;
|
||||
};
|
||||
|
||||
export const migrateSmartconfigData = (
|
||||
smartconfigJson: Record<string, any>,
|
||||
targetVersion = CURRENT_GITZONE_CLI_SCHEMA_VERSION,
|
||||
): ISmartconfigMigrationResult => {
|
||||
let migrated = false;
|
||||
migrated = migrateNamespaceKeys(smartconfigJson) || migrated;
|
||||
|
||||
const cliConfig = ensureObject(smartconfigJson, CLI_NAMESPACE);
|
||||
const fromVersion = typeof cliConfig.schemaVersion === "number" ? cliConfig.schemaVersion : 1;
|
||||
let currentVersion = fromVersion;
|
||||
|
||||
if (currentVersion < 2 && targetVersion >= 2) {
|
||||
migrated = migrateToV2(smartconfigJson) || migrated;
|
||||
currentVersion = 2;
|
||||
}
|
||||
|
||||
if (targetVersion === CURRENT_GITZONE_CLI_SCHEMA_VERSION && cliConfig.schemaVersion !== targetVersion) {
|
||||
cliConfig.schemaVersion = targetVersion;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
return {
|
||||
migrated,
|
||||
fromVersion,
|
||||
toVersion: Math.min(targetVersion, CURRENT_GITZONE_CLI_SCHEMA_VERSION),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,387 @@
|
||||
import { getCliConfigValue } from "./helpers.smartconfig.js";
|
||||
|
||||
export type TConfirmationMode = "prompt" | "auto" | "plan";
|
||||
|
||||
export type TCommitStep =
|
||||
| "format"
|
||||
| "analyze"
|
||||
| "test"
|
||||
| "build"
|
||||
| "changelog"
|
||||
| "commit"
|
||||
| "push";
|
||||
|
||||
export type TReleaseTarget = "git" | "npm" | "docker";
|
||||
|
||||
export interface ICommitWorkflowConfig {
|
||||
confirmation?: TConfirmationMode;
|
||||
staging?: "all";
|
||||
steps?: TCommitStep[];
|
||||
alwaysTest?: boolean;
|
||||
alwaysBuild?: boolean;
|
||||
analyze?: {
|
||||
provider?: "ai";
|
||||
requireConfirmationFor?: string[];
|
||||
};
|
||||
test?: {
|
||||
command?: string;
|
||||
};
|
||||
build?: {
|
||||
command?: string;
|
||||
verifyCleanTree?: boolean;
|
||||
};
|
||||
push?: {
|
||||
remote?: string;
|
||||
followTags?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReleaseGitTargetConfig {
|
||||
enabled?: boolean;
|
||||
remote?: string;
|
||||
pushBranch?: boolean;
|
||||
pushTags?: boolean;
|
||||
}
|
||||
|
||||
export interface IReleaseNpmTargetConfig {
|
||||
enabled?: boolean;
|
||||
registries?: string[];
|
||||
accessLevel?: "public" | "private";
|
||||
alreadyPublished?: "success" | "error";
|
||||
}
|
||||
|
||||
export interface IReleaseDockerTargetConfig {
|
||||
enabled?: boolean;
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
export interface IReleaseWorkflowConfig {
|
||||
confirmation?: TConfirmationMode;
|
||||
version?: {
|
||||
strategy?: "semver";
|
||||
source?: "pendingChangelog" | "manual";
|
||||
};
|
||||
preflight?: {
|
||||
requireCleanTree?: boolean;
|
||||
test?: boolean;
|
||||
build?: boolean;
|
||||
testCommand?: string;
|
||||
buildCommand?: string;
|
||||
};
|
||||
targets?: {
|
||||
git?: IReleaseGitTargetConfig;
|
||||
npm?: IReleaseNpmTargetConfig;
|
||||
docker?: IReleaseDockerTargetConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IResolvedCommitWorkflow {
|
||||
confirmation: TConfirmationMode;
|
||||
steps: TCommitStep[];
|
||||
staging: "all";
|
||||
testCommand: string;
|
||||
buildCommand: string;
|
||||
changelogFile: "changelog.md";
|
||||
changelogSection: "Pending";
|
||||
pushRemote: string;
|
||||
pushFollowTags: boolean;
|
||||
releaseFlagRequested: boolean;
|
||||
}
|
||||
|
||||
export interface IResolvedReleaseWorkflow {
|
||||
confirmation: TConfirmationMode;
|
||||
plan: string[];
|
||||
targets: TReleaseTarget[];
|
||||
requireCleanTree: boolean;
|
||||
runTests: boolean;
|
||||
runBuild: boolean;
|
||||
testCommand: string;
|
||||
buildCommand: string;
|
||||
changelogFile: "changelog.md";
|
||||
changelogPendingSection: "Pending";
|
||||
changelogVersionHeading: "## {{date}} - {{version}}";
|
||||
gitEnabled: boolean;
|
||||
gitRemote: string;
|
||||
pushBranch: boolean;
|
||||
pushTags: boolean;
|
||||
npmEnabled: boolean;
|
||||
npmRegistries: string[];
|
||||
npmAccessLevel: "public" | "private";
|
||||
npmAlreadyPublished: "success" | "error";
|
||||
dockerEnabled: boolean;
|
||||
dockerImages: string[];
|
||||
}
|
||||
|
||||
interface ICliWorkflowConfig {
|
||||
commit?: ICommitWorkflowConfig;
|
||||
release?: IReleaseWorkflowConfig;
|
||||
}
|
||||
|
||||
const commitFlagToStep: Record<string, TCommitStep | undefined> = {
|
||||
f: "format",
|
||||
t: "test",
|
||||
b: "build",
|
||||
p: "push",
|
||||
};
|
||||
|
||||
const unique = <T>(items: T[]): T[] => {
|
||||
const result: T[] = [];
|
||||
for (const item of items) {
|
||||
if (!result.includes(item)) {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const normalizeConfirmation = (
|
||||
value: unknown,
|
||||
fallback: TConfirmationMode,
|
||||
): TConfirmationMode => {
|
||||
if (value === "prompt" || value === "auto" || value === "plan") {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const normalizeRegistryUrl = (url: string): string => {
|
||||
let normalizedUrl = url.trim();
|
||||
if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
|
||||
normalizedUrl = `https://${normalizedUrl}`;
|
||||
}
|
||||
return normalizedUrl.endsWith("/") ? normalizedUrl.slice(0, -1) : normalizedUrl;
|
||||
};
|
||||
|
||||
const isDisabled = (argvArg: any, ...keys: string[]): boolean => {
|
||||
return keys.some((key) => argvArg[key] === false || argvArg[`no-${key}`] || argvArg[`no${key[0].toUpperCase()}${key.slice(1)}`]);
|
||||
};
|
||||
|
||||
const readCliWorkflowConfig = async (): Promise<ICliWorkflowConfig> => {
|
||||
return await getCliConfigValue<ICliWorkflowConfig>("", {});
|
||||
};
|
||||
|
||||
const getOrderedArgsAfterCommand = (commandName: string): string[] => {
|
||||
const rawArgs = process.argv.slice(2);
|
||||
const commandIndex = rawArgs.indexOf(commandName);
|
||||
if (commandIndex === -1) {
|
||||
return rawArgs;
|
||||
}
|
||||
return rawArgs.slice(commandIndex + 1);
|
||||
};
|
||||
|
||||
const getOrderedShortFlags = (commandName: string): string[] => {
|
||||
const orderedFlags: string[] = [];
|
||||
for (const arg of getOrderedArgsAfterCommand(commandName)) {
|
||||
if (arg === "--") {
|
||||
break;
|
||||
}
|
||||
if (arg.startsWith("--")) {
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("-") && arg.length > 1) {
|
||||
orderedFlags.push(...arg.slice(1).split(""));
|
||||
}
|
||||
}
|
||||
return orderedFlags;
|
||||
};
|
||||
|
||||
const hasExplicitCommitWorkflowFlags = (argvArg: any): boolean => {
|
||||
return Boolean(
|
||||
argvArg.f ||
|
||||
argvArg.format ||
|
||||
argvArg.t ||
|
||||
argvArg.test ||
|
||||
argvArg.b ||
|
||||
argvArg.build ||
|
||||
argvArg.p ||
|
||||
argvArg.push,
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeCommitSteps = (rawSteps: TCommitStep[]): TCommitStep[] => {
|
||||
const steps = unique(rawSteps.filter(Boolean));
|
||||
const pushRequested = steps.includes("push");
|
||||
const prePushSteps = steps.filter((step) => step !== "push");
|
||||
|
||||
if (!prePushSteps.includes("analyze")) {
|
||||
prePushSteps.unshift("analyze");
|
||||
}
|
||||
|
||||
if (!prePushSteps.includes("changelog")) {
|
||||
const commitIndex = prePushSteps.indexOf("commit");
|
||||
if (commitIndex === -1) {
|
||||
prePushSteps.push("changelog");
|
||||
} else {
|
||||
prePushSteps.splice(commitIndex, 0, "changelog");
|
||||
}
|
||||
}
|
||||
|
||||
if (!prePushSteps.includes("commit")) {
|
||||
prePushSteps.push("commit");
|
||||
}
|
||||
|
||||
const analyzeIndex = prePushSteps.indexOf("analyze");
|
||||
const commitIndex = prePushSteps.indexOf("commit");
|
||||
if (analyzeIndex > commitIndex) {
|
||||
throw new Error("Commit workflow requires analyze before commit.");
|
||||
}
|
||||
|
||||
const changelogIndex = prePushSteps.indexOf("changelog");
|
||||
if (changelogIndex === -1 || changelogIndex > commitIndex) {
|
||||
throw new Error("Commit workflow requires changelog before commit.");
|
||||
}
|
||||
|
||||
return pushRequested ? [...prePushSteps, "push"] : prePushSteps;
|
||||
};
|
||||
|
||||
const getTargetOverride = (argvArg: any): TReleaseTarget[] | undefined => {
|
||||
const validTargets: TReleaseTarget[] = ["git", "npm", "docker"];
|
||||
const rawTargets = argvArg.target || argvArg.targets;
|
||||
if (typeof rawTargets === "string") {
|
||||
return rawTargets
|
||||
.split(",")
|
||||
.map((target) => target.trim())
|
||||
.filter((target): target is TReleaseTarget => validTargets.includes(target as TReleaseTarget));
|
||||
}
|
||||
|
||||
const targets: TReleaseTarget[] = [];
|
||||
if (argvArg.git || argvArg.p || argvArg.push) targets.push("git");
|
||||
if (argvArg.npm) targets.push("npm");
|
||||
if (argvArg.docker) targets.push("docker");
|
||||
return targets.length > 0 ? targets : undefined;
|
||||
};
|
||||
|
||||
const buildReleasePlan = (options: {
|
||||
requireCleanTree: boolean;
|
||||
runTests: boolean;
|
||||
runBuild: boolean;
|
||||
targets: TReleaseTarget[];
|
||||
}): string[] => {
|
||||
const plan: string[] = [];
|
||||
if (options.requireCleanTree) plan.push("preflight.cleanTree");
|
||||
if (options.runTests) plan.push("preflight.test");
|
||||
plan.push("core.version", "core.changelog", "core.commit", "core.tag");
|
||||
if (options.runBuild) plan.push("core.build");
|
||||
for (const target of options.targets) {
|
||||
plan.push(`target.${target}`);
|
||||
}
|
||||
return plan;
|
||||
};
|
||||
|
||||
export const resolveCommitWorkflow = async (argvArg: any): Promise<IResolvedCommitWorkflow> => {
|
||||
const cliConfig = await readCliWorkflowConfig();
|
||||
const commitConfig = cliConfig.commit || {};
|
||||
const releaseFlagRequested = Boolean(argvArg.r || argvArg.release);
|
||||
|
||||
let confirmation = normalizeConfirmation(commitConfig.confirmation, "prompt");
|
||||
if (argvArg.plan) {
|
||||
confirmation = "plan";
|
||||
} else if (argvArg.y || argvArg.yes) {
|
||||
confirmation = "auto";
|
||||
}
|
||||
|
||||
let rawSteps: TCommitStep[];
|
||||
if (hasExplicitCommitWorkflowFlags(argvArg)) {
|
||||
const orderedFlags = getOrderedShortFlags("commit");
|
||||
rawSteps = ["analyze"];
|
||||
for (const shortFlag of orderedFlags) {
|
||||
const step = commitFlagToStep[shortFlag];
|
||||
if (step) {
|
||||
rawSteps.push(step);
|
||||
}
|
||||
}
|
||||
if (argvArg.format && !rawSteps.includes("format")) rawSteps.push("format");
|
||||
if (argvArg.test && !rawSteps.includes("test")) rawSteps.push("test");
|
||||
if (argvArg.build && !rawSteps.includes("build")) rawSteps.push("build");
|
||||
if (argvArg.push && !rawSteps.includes("push")) rawSteps.push("push");
|
||||
rawSteps.push("changelog");
|
||||
rawSteps.push("commit");
|
||||
} else if (Array.isArray(commitConfig.steps) && commitConfig.steps.length > 0) {
|
||||
rawSteps = commitConfig.steps;
|
||||
} else {
|
||||
rawSteps = ["analyze"];
|
||||
if (commitConfig.alwaysTest) rawSteps.push("test");
|
||||
if (commitConfig.alwaysBuild) rawSteps.push("build");
|
||||
rawSteps.push("changelog");
|
||||
rawSteps.push("commit");
|
||||
}
|
||||
|
||||
return {
|
||||
confirmation,
|
||||
steps: normalizeCommitSteps(rawSteps),
|
||||
staging: commitConfig.staging || "all",
|
||||
testCommand: commitConfig.test?.command || "pnpm test",
|
||||
buildCommand: commitConfig.build?.command || "pnpm build",
|
||||
changelogFile: "changelog.md",
|
||||
changelogSection: "Pending",
|
||||
pushRemote: commitConfig.push?.remote || "origin",
|
||||
pushFollowTags: commitConfig.push?.followTags || false,
|
||||
releaseFlagRequested,
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveReleaseWorkflow = async (argvArg: any): Promise<IResolvedReleaseWorkflow> => {
|
||||
const cliConfig = await readCliWorkflowConfig();
|
||||
const releaseConfig = cliConfig.release || {};
|
||||
const targetConfig = releaseConfig.targets || {};
|
||||
const gitConfig = targetConfig.git || {};
|
||||
const npmConfig = targetConfig.npm || {};
|
||||
const dockerConfig = targetConfig.docker || {};
|
||||
const npmRegistries = (npmConfig.registries || []).map(normalizeRegistryUrl);
|
||||
const npmEnabled = npmConfig.enabled ?? npmRegistries.length > 0;
|
||||
const gitEnabled = gitConfig.enabled ?? true;
|
||||
const dockerEnabled = dockerConfig.enabled ?? false;
|
||||
|
||||
let confirmation = normalizeConfirmation(releaseConfig.confirmation, "prompt");
|
||||
if (argvArg.plan) {
|
||||
confirmation = "plan";
|
||||
} else if (argvArg.y || argvArg.yes) {
|
||||
confirmation = "auto";
|
||||
}
|
||||
|
||||
let requireCleanTree = releaseConfig.preflight?.requireCleanTree ?? true;
|
||||
let runTests = releaseConfig.preflight?.test ?? false;
|
||||
let runBuild = releaseConfig.preflight?.build ?? true;
|
||||
if (argvArg.t || argvArg.test) runTests = true;
|
||||
if (argvArg.b || argvArg.build) runBuild = true;
|
||||
if (isDisabled(argvArg, "test")) runTests = false;
|
||||
if (isDisabled(argvArg, "build")) runBuild = false;
|
||||
if (isDisabled(argvArg, "preflight")) requireCleanTree = false;
|
||||
|
||||
const configuredTargets: TReleaseTarget[] = [];
|
||||
if (gitEnabled) configuredTargets.push("git");
|
||||
if (npmEnabled) configuredTargets.push("npm");
|
||||
if (dockerEnabled) configuredTargets.push("docker");
|
||||
let targets = getTargetOverride(argvArg) || configuredTargets;
|
||||
if (isDisabled(argvArg, "git", "push")) {
|
||||
targets = targets.filter((target) => target !== "git");
|
||||
}
|
||||
if (isDisabled(argvArg, "publish")) {
|
||||
targets = targets.filter((target) => target === "git");
|
||||
}
|
||||
targets = unique(targets);
|
||||
|
||||
return {
|
||||
confirmation,
|
||||
plan: buildReleasePlan({ requireCleanTree, runTests, runBuild, targets }),
|
||||
targets,
|
||||
requireCleanTree,
|
||||
runTests,
|
||||
runBuild,
|
||||
testCommand: releaseConfig.preflight?.testCommand || "pnpm test",
|
||||
buildCommand: releaseConfig.preflight?.buildCommand || "pnpm build",
|
||||
changelogFile: "changelog.md",
|
||||
changelogPendingSection: "Pending",
|
||||
changelogVersionHeading: "## {{date}} - {{version}}",
|
||||
gitEnabled,
|
||||
gitRemote: gitConfig.remote || "origin",
|
||||
pushBranch: gitConfig.pushBranch ?? true,
|
||||
pushTags: gitConfig.pushTags ?? true,
|
||||
npmEnabled,
|
||||
npmRegistries,
|
||||
npmAccessLevel: npmConfig.accessLevel || "public",
|
||||
npmAlreadyPublished: npmConfig.alreadyPublished || "success",
|
||||
dockerEnabled,
|
||||
dockerImages: dockerConfig.images || [],
|
||||
};
|
||||
};
|
||||
+249
-451
@@ -3,15 +3,11 @@
|
||||
import * as plugins from "./mod.plugins.js";
|
||||
import * as paths from "../paths.js";
|
||||
import { logger } from "../gitzone.logging.js";
|
||||
import * as helpers from "./mod.helpers.js";
|
||||
import * as ui from "./mod.ui.js";
|
||||
import { ReleaseConfig } from "../mod_config/classes.releaseconfig.js";
|
||||
import type { ICliMode } from "../helpers.climode.js";
|
||||
import {
|
||||
getCliMode,
|
||||
printJson,
|
||||
runWithSuppressedOutput,
|
||||
} from "../helpers.climode.js";
|
||||
import { getCliMode, printJson, runWithSuppressedOutput } from "../helpers.climode.js";
|
||||
import { appendPendingChangelogEntry } from "../helpers.changelog.js";
|
||||
import { resolveCommitWorkflow, type IResolvedCommitWorkflow } from "../helpers.workflow.js";
|
||||
|
||||
export const run = async (argvArg: any) => {
|
||||
const mode = await getCliMode(argvArg);
|
||||
@@ -36,431 +32,247 @@ export const run = async (argvArg: any) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read commit config from .smartconfig.json
|
||||
const smartconfigInstance = new plugins.smartconfig.Smartconfig();
|
||||
const gitzoneConfig = smartconfigInstance.dataFor<{
|
||||
commit?: {
|
||||
alwaysTest?: boolean;
|
||||
alwaysBuild?: boolean;
|
||||
};
|
||||
}>("@git.zone/cli", {});
|
||||
const commitConfig = gitzoneConfig.commit || {};
|
||||
|
||||
// Check flags and merge with config options
|
||||
const wantsRelease = !!(argvArg.r || argvArg.release);
|
||||
const wantsTest = !!(argvArg.t || argvArg.test || commitConfig.alwaysTest);
|
||||
const wantsBuild = !!(argvArg.b || argvArg.build || commitConfig.alwaysBuild);
|
||||
let releaseConfig: ReleaseConfig | null = null;
|
||||
|
||||
if (wantsRelease) {
|
||||
releaseConfig = await ReleaseConfig.fromCwd();
|
||||
if (!releaseConfig.hasRegistries()) {
|
||||
logger.log("error", "No release registries configured.");
|
||||
console.log("");
|
||||
console.log(
|
||||
" Run `gitzone config add <registry-url>` to add registries.",
|
||||
);
|
||||
console.log("");
|
||||
process.exit(1);
|
||||
}
|
||||
const workflow = await resolveCommitWorkflow(argvArg);
|
||||
if (workflow.releaseFlagRequested) {
|
||||
logger.log(
|
||||
"warn",
|
||||
"`gitzone commit -r` is deprecated and no longer releases. Use `gitzone release` after committing.",
|
||||
);
|
||||
}
|
||||
|
||||
// Print execution plan at the start
|
||||
ui.printExecutionPlan({
|
||||
autoAccept: !!(argvArg.y || argvArg.yes),
|
||||
push: !!(argvArg.p || argvArg.push),
|
||||
test: wantsTest,
|
||||
build: wantsBuild,
|
||||
release: wantsRelease,
|
||||
format: !!argvArg.format,
|
||||
registries: releaseConfig?.getRegistries(),
|
||||
});
|
||||
|
||||
if (argvArg.format) {
|
||||
const formatMod = await import("../mod_format/index.js");
|
||||
await formatMod.run();
|
||||
printCommitExecutionPlan(workflow);
|
||||
if (workflow.confirmation === "plan") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Run tests early to fail fast before analysis
|
||||
if (wantsTest) {
|
||||
ui.printHeader("🧪 Running tests...");
|
||||
const smartshellForTest = new plugins.smartshell.Smartshell({
|
||||
executor: "bash",
|
||||
sourceFilePaths: [],
|
||||
});
|
||||
const testResult = await smartshellForTest.exec("pnpm test");
|
||||
if (testResult.exitCode !== 0) {
|
||||
logger.log("error", "Tests failed. Aborting commit.");
|
||||
process.exit(1);
|
||||
}
|
||||
logger.log("success", "All tests passed.");
|
||||
}
|
||||
|
||||
ui.printHeader("🔍 Analyzing repository changes...");
|
||||
|
||||
const aidoc = new plugins.tsdoc.AiDoc();
|
||||
await aidoc.start();
|
||||
|
||||
const nextCommitObject = await aidoc.buildNextCommitObject(paths.cwd);
|
||||
|
||||
await aidoc.stop();
|
||||
|
||||
ui.printRecommendation({
|
||||
recommendedNextVersion: nextCommitObject.recommendedNextVersion,
|
||||
recommendedNextVersionLevel: nextCommitObject.recommendedNextVersionLevel,
|
||||
recommendedNextVersionScope: nextCommitObject.recommendedNextVersionScope,
|
||||
recommendedNextVersionMessage:
|
||||
nextCommitObject.recommendedNextVersionMessage,
|
||||
});
|
||||
|
||||
let answerBucket: plugins.smartinteract.AnswerBucket;
|
||||
|
||||
// Check if -y/--yes flag is set AND version is not a breaking change
|
||||
// Breaking changes (major version bumps) always require manual confirmation
|
||||
const isBreakingChange =
|
||||
nextCommitObject.recommendedNextVersionLevel === "BREAKING CHANGE";
|
||||
const canAutoAccept = (argvArg.y || argvArg.yes) && !isBreakingChange;
|
||||
|
||||
if (canAutoAccept) {
|
||||
// Auto-mode: create AnswerBucket programmatically
|
||||
logger.log("info", "✓ Auto-accepting AI recommendations (--yes flag)");
|
||||
|
||||
answerBucket = new plugins.smartinteract.AnswerBucket();
|
||||
answerBucket.addAnswer({
|
||||
name: "commitType",
|
||||
value: nextCommitObject.recommendedNextVersionLevel,
|
||||
});
|
||||
answerBucket.addAnswer({
|
||||
name: "commitScope",
|
||||
value: nextCommitObject.recommendedNextVersionScope,
|
||||
});
|
||||
answerBucket.addAnswer({
|
||||
name: "commitDescription",
|
||||
value: nextCommitObject.recommendedNextVersionMessage,
|
||||
});
|
||||
answerBucket.addAnswer({
|
||||
name: "pushToOrigin",
|
||||
value: !!(argvArg.p || argvArg.push), // Only push if -p flag also provided
|
||||
});
|
||||
answerBucket.addAnswer({
|
||||
name: "createRelease",
|
||||
value: wantsRelease,
|
||||
});
|
||||
} else {
|
||||
// Warn if --yes was provided but we're requiring confirmation due to breaking change
|
||||
if (isBreakingChange && (argvArg.y || argvArg.yes)) {
|
||||
logger.log(
|
||||
"warn",
|
||||
"⚠️ BREAKING CHANGE detected - manual confirmation required",
|
||||
);
|
||||
}
|
||||
// Interactive mode: prompt user for input
|
||||
const commitInteract = new plugins.smartinteract.SmartInteract();
|
||||
commitInteract.addQuestions([
|
||||
{
|
||||
type: "list",
|
||||
name: `commitType`,
|
||||
message: `Choose TYPE of the commit:`,
|
||||
choices: [`fix`, `feat`, `BREAKING CHANGE`],
|
||||
default: nextCommitObject.recommendedNextVersionLevel,
|
||||
},
|
||||
{
|
||||
type: "input",
|
||||
name: `commitScope`,
|
||||
message: `What is the SCOPE of the commit:`,
|
||||
default: nextCommitObject.recommendedNextVersionScope,
|
||||
},
|
||||
{
|
||||
type: `input`,
|
||||
name: `commitDescription`,
|
||||
message: `What is the DESCRIPTION of the commit?`,
|
||||
default: nextCommitObject.recommendedNextVersionMessage,
|
||||
},
|
||||
{
|
||||
type: "confirm",
|
||||
name: `pushToOrigin`,
|
||||
message: `Do you want to push this version now?`,
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
type: "confirm",
|
||||
name: `createRelease`,
|
||||
message: `Do you want to publish to npm registries?`,
|
||||
default: wantsRelease,
|
||||
},
|
||||
]);
|
||||
answerBucket = await commitInteract.runQueue();
|
||||
}
|
||||
const commitString = createCommitStringFromAnswerBucket(answerBucket);
|
||||
const commitType = answerBucket.getAnswerFor("commitType");
|
||||
let commitVersionType: helpers.VersionType;
|
||||
switch (commitType) {
|
||||
case "fix":
|
||||
commitVersionType = "patch";
|
||||
break;
|
||||
case "feat":
|
||||
commitVersionType = "minor";
|
||||
break;
|
||||
case "BREAKING CHANGE":
|
||||
commitVersionType = "major";
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported commit type: ${commitType}`);
|
||||
}
|
||||
|
||||
ui.printHeader("✨ Creating Semantic Commit");
|
||||
ui.printCommitMessage(commitString);
|
||||
const smartshellInstance = new plugins.smartshell.Smartshell({
|
||||
executor: "bash",
|
||||
sourceFilePaths: [],
|
||||
});
|
||||
|
||||
// Load release config if user wants to release (interactively selected)
|
||||
if (answerBucket.getAnswerFor("createRelease") && !releaseConfig) {
|
||||
releaseConfig = await ReleaseConfig.fromCwd();
|
||||
if (!releaseConfig.hasRegistries()) {
|
||||
logger.log("error", "No release registries configured.");
|
||||
console.log("");
|
||||
console.log(
|
||||
" Run `gitzone config add <registry-url>` to add registries.",
|
||||
);
|
||||
console.log("");
|
||||
process.exit(1);
|
||||
let nextCommitObject: any;
|
||||
let answerBucket: plugins.smartinteract.AnswerBucket | undefined;
|
||||
|
||||
for (const step of workflow.steps) {
|
||||
switch (step) {
|
||||
case "format":
|
||||
await runFormatStep();
|
||||
break;
|
||||
case "test":
|
||||
await runCommandStep(smartshellInstance, "Running tests", workflow.testCommand);
|
||||
break;
|
||||
case "build":
|
||||
await runCommandStep(smartshellInstance, "Running build", workflow.buildCommand);
|
||||
break;
|
||||
case "analyze":
|
||||
nextCommitObject = await runAnalyzeStep();
|
||||
answerBucket = await buildAnswerBucket(nextCommitObject, workflow, mode, argvArg);
|
||||
break;
|
||||
case "changelog":
|
||||
assertAnalysisComplete(answerBucket, nextCommitObject);
|
||||
await runChangelogStep(workflow, answerBucket!, nextCommitObject);
|
||||
break;
|
||||
case "commit":
|
||||
assertAnalysisComplete(answerBucket, nextCommitObject);
|
||||
await runCommitStep(smartshellInstance, answerBucket!);
|
||||
break;
|
||||
case "push":
|
||||
await runPushStep(smartshellInstance, workflow);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine total steps based on options
|
||||
// Note: test runs early (like format) so not counted in numbered steps
|
||||
const willPush =
|
||||
answerBucket.getAnswerFor("pushToOrigin") && !(process.env.CI === "true");
|
||||
const willRelease =
|
||||
answerBucket.getAnswerFor("createRelease") &&
|
||||
releaseConfig?.hasRegistries();
|
||||
let totalSteps = 5; // Base steps: commitinfo, changelog, staging, commit, version
|
||||
if (wantsBuild) totalSteps += 2; // build step + verification step
|
||||
if (willPush) totalSteps++;
|
||||
if (willRelease) totalSteps++;
|
||||
let currentStep = 0;
|
||||
|
||||
// Step 1: Baking commitinfo
|
||||
currentStep++;
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
"🔧 Baking commit info into code",
|
||||
"in-progress",
|
||||
);
|
||||
const commitInfo = new plugins.commitinfo.CommitInfo(
|
||||
paths.cwd,
|
||||
commitVersionType,
|
||||
);
|
||||
await commitInfo.writeIntoPotentialDirs();
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
"🔧 Baking commit info into code",
|
||||
"done",
|
||||
);
|
||||
|
||||
// Step 2: Writing changelog
|
||||
currentStep++;
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
"📄 Generating changelog.md",
|
||||
"in-progress",
|
||||
);
|
||||
let changelog = nextCommitObject.changelog || "# Changelog\n";
|
||||
changelog = changelog.replaceAll(
|
||||
"{{nextVersion}}",
|
||||
(await commitInfo.getNextPlannedVersion()).versionString,
|
||||
);
|
||||
changelog = changelog.replaceAll(
|
||||
"{{nextVersionScope}}",
|
||||
`${await answerBucket.getAnswerFor("commitType")}(${await answerBucket.getAnswerFor("commitScope")})`,
|
||||
);
|
||||
changelog = changelog.replaceAll(
|
||||
"{{nextVersionMessage}}",
|
||||
nextCommitObject.recommendedNextVersionMessage,
|
||||
);
|
||||
if (nextCommitObject.recommendedNextVersionDetails?.length > 0) {
|
||||
changelog = changelog.replaceAll(
|
||||
"{{nextVersionDetails}}",
|
||||
"- " + nextCommitObject.recommendedNextVersionDetails.join("\n- "),
|
||||
);
|
||||
} else {
|
||||
changelog = changelog.replaceAll("\n{{nextVersionDetails}}", "");
|
||||
}
|
||||
|
||||
await plugins.smartfs
|
||||
.file(plugins.path.join(paths.cwd, `changelog.md`))
|
||||
.encoding("utf8")
|
||||
.write(changelog);
|
||||
ui.printStep(currentStep, totalSteps, "📄 Generating changelog.md", "done");
|
||||
|
||||
// Step 3: Staging files
|
||||
currentStep++;
|
||||
ui.printStep(currentStep, totalSteps, "📦 Staging files", "in-progress");
|
||||
await smartshellInstance.exec(`git add -A`);
|
||||
ui.printStep(currentStep, totalSteps, "📦 Staging files", "done");
|
||||
|
||||
// Step 4: Creating commit
|
||||
currentStep++;
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
"💾 Creating git commit",
|
||||
"in-progress",
|
||||
);
|
||||
await smartshellInstance.exec(`git commit -m "${commitString}"`);
|
||||
ui.printStep(currentStep, totalSteps, "💾 Creating git commit", "done");
|
||||
|
||||
// Step 5: Bumping version
|
||||
currentStep++;
|
||||
const projectType = await helpers.detectProjectType();
|
||||
const newVersion = await helpers.bumpProjectVersion(
|
||||
projectType,
|
||||
commitVersionType,
|
||||
currentStep,
|
||||
totalSteps,
|
||||
);
|
||||
|
||||
// Step 6: Run build (optional)
|
||||
if (wantsBuild) {
|
||||
currentStep++;
|
||||
ui.printStep(currentStep, totalSteps, "🔨 Running build", "in-progress");
|
||||
const buildResult = await smartshellInstance.exec("pnpm build");
|
||||
if (buildResult.exitCode !== 0) {
|
||||
ui.printStep(currentStep, totalSteps, "🔨 Running build", "error");
|
||||
logger.log("error", "Build failed. Aborting release.");
|
||||
process.exit(1);
|
||||
}
|
||||
ui.printStep(currentStep, totalSteps, "🔨 Running build", "done");
|
||||
|
||||
// Step 7: Verify no uncommitted changes
|
||||
currentStep++;
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
"🔍 Verifying clean working tree",
|
||||
"in-progress",
|
||||
);
|
||||
const statusResult = await smartshellInstance.exec(
|
||||
"git status --porcelain",
|
||||
);
|
||||
if (statusResult.stdout.trim() !== "") {
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
"🔍 Verifying clean working tree",
|
||||
"error",
|
||||
);
|
||||
logger.log(
|
||||
"error",
|
||||
"Build produced uncommitted changes. This usually means build output is not gitignored.",
|
||||
);
|
||||
logger.log("error", "Uncommitted files:");
|
||||
console.log(statusResult.stdout);
|
||||
logger.log(
|
||||
"error",
|
||||
"Aborting release. Please ensure build artifacts are in .gitignore",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
"🔍 Verifying clean working tree",
|
||||
"done",
|
||||
);
|
||||
}
|
||||
|
||||
// Step: Push to remote (optional)
|
||||
const currentBranch = await helpers.detectCurrentBranch();
|
||||
if (willPush) {
|
||||
currentStep++;
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
`🚀 Pushing to origin/${currentBranch}`,
|
||||
"in-progress",
|
||||
);
|
||||
await smartshellInstance.exec(
|
||||
`git push origin ${currentBranch} --follow-tags`,
|
||||
);
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
`🚀 Pushing to origin/${currentBranch}`,
|
||||
"done",
|
||||
);
|
||||
}
|
||||
|
||||
// Step 7: Publish to npm registries (optional)
|
||||
let releasedRegistries: string[] = [];
|
||||
if (willRelease && releaseConfig) {
|
||||
currentStep++;
|
||||
const registries = releaseConfig.getRegistries();
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
`📦 Publishing to ${registries.length} registr${registries.length === 1 ? "y" : "ies"}`,
|
||||
"in-progress",
|
||||
);
|
||||
|
||||
const accessLevel = releaseConfig.getAccessLevel();
|
||||
for (const registry of registries) {
|
||||
try {
|
||||
await smartshellInstance.exec(
|
||||
`npm publish --registry=${registry} --access=${accessLevel}`,
|
||||
);
|
||||
releasedRegistries.push(registry);
|
||||
} catch (error) {
|
||||
logger.log("error", `Failed to publish to ${registry}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (releasedRegistries.length === registries.length) {
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
`📦 Publishing to ${registries.length} registr${registries.length === 1 ? "y" : "ies"}`,
|
||||
"done",
|
||||
);
|
||||
} else {
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
`📦 Publishing to ${registries.length} registr${registries.length === 1 ? "y" : "ies"}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(""); // Add spacing before summary
|
||||
|
||||
// Get commit SHA for summary
|
||||
const commitShaResult = await smartshellInstance.exec(
|
||||
"git rev-parse --short HEAD",
|
||||
);
|
||||
const commitSha = commitShaResult.stdout.trim();
|
||||
|
||||
// Print final summary
|
||||
const commitShaResult = await smartshellInstance.exec("git rev-parse --short HEAD");
|
||||
const currentBranch = await detectCurrentBranch(smartshellInstance);
|
||||
ui.printSummary({
|
||||
projectType,
|
||||
projectType: "source",
|
||||
branch: currentBranch,
|
||||
commitType: answerBucket.getAnswerFor("commitType"),
|
||||
commitScope: answerBucket.getAnswerFor("commitScope"),
|
||||
commitMessage: answerBucket.getAnswerFor("commitDescription"),
|
||||
newVersion: newVersion,
|
||||
commitSha: commitSha,
|
||||
pushed: willPush,
|
||||
released: releasedRegistries.length > 0,
|
||||
releasedRegistries:
|
||||
releasedRegistries.length > 0 ? releasedRegistries : undefined,
|
||||
commitType: answerBucket!.getAnswerFor("commitType"),
|
||||
commitScope: answerBucket!.getAnswerFor("commitScope"),
|
||||
commitMessage: answerBucket!.getAnswerFor("commitDescription"),
|
||||
commitSha: commitShaResult.stdout.trim(),
|
||||
pushed: workflow.steps.includes("push"),
|
||||
});
|
||||
};
|
||||
|
||||
async function runFormatStep(): Promise<void> {
|
||||
ui.printHeader("Formatting project files");
|
||||
const formatMod = await import("../mod_format/index.js");
|
||||
await formatMod.run({ write: true, yes: true, interactive: false });
|
||||
}
|
||||
|
||||
async function runCommandStep(
|
||||
smartshellInstance: plugins.smartshell.Smartshell,
|
||||
label: string,
|
||||
command: string,
|
||||
): Promise<void> {
|
||||
ui.printHeader(label);
|
||||
const result = await smartshellInstance.exec(command);
|
||||
if (result.exitCode !== 0) {
|
||||
logger.log("error", `${label} failed. Aborting commit.`);
|
||||
process.exit(1);
|
||||
}
|
||||
logger.log("success", `${label} passed.`);
|
||||
}
|
||||
|
||||
async function runAnalyzeStep(): Promise<any> {
|
||||
ui.printHeader("Analyzing repository changes");
|
||||
const aidoc = new plugins.tsdoc.AiDoc();
|
||||
await aidoc.start();
|
||||
try {
|
||||
const nextCommitObject = await aidoc.buildNextCommitObject(paths.cwd);
|
||||
ui.printRecommendation({
|
||||
recommendedNextVersion: nextCommitObject.recommendedNextVersion,
|
||||
recommendedNextVersionLevel: nextCommitObject.recommendedNextVersionLevel,
|
||||
recommendedNextVersionScope: nextCommitObject.recommendedNextVersionScope,
|
||||
recommendedNextVersionMessage: nextCommitObject.recommendedNextVersionMessage,
|
||||
});
|
||||
return nextCommitObject;
|
||||
} finally {
|
||||
await aidoc.stop();
|
||||
}
|
||||
}
|
||||
|
||||
async function buildAnswerBucket(
|
||||
nextCommitObject: any,
|
||||
workflow: IResolvedCommitWorkflow,
|
||||
mode: ICliMode,
|
||||
argvArg: any,
|
||||
): Promise<plugins.smartinteract.AnswerBucket> {
|
||||
const isBreakingChange = nextCommitObject.recommendedNextVersionLevel === "BREAKING CHANGE";
|
||||
const canAutoAccept = workflow.confirmation === "auto" && !isBreakingChange;
|
||||
|
||||
if (canAutoAccept) {
|
||||
logger.log("info", "Auto-accepting AI recommendations");
|
||||
return createAnswerBucket({
|
||||
commitType: nextCommitObject.recommendedNextVersionLevel,
|
||||
commitScope: nextCommitObject.recommendedNextVersionScope,
|
||||
commitDescription: nextCommitObject.recommendedNextVersionMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (isBreakingChange && (workflow.confirmation === "auto" || argvArg.y || argvArg.yes)) {
|
||||
logger.log("warn", "BREAKING CHANGE detected - manual confirmation required");
|
||||
}
|
||||
|
||||
if (!mode.interactive) {
|
||||
throw new Error("Commit confirmation requires an interactive terminal. Use `-y` or set commit.confirmation to `auto`.");
|
||||
}
|
||||
|
||||
const commitInteract = new plugins.smartinteract.SmartInteract();
|
||||
commitInteract.addQuestions([
|
||||
{
|
||||
type: "list",
|
||||
name: "commitType",
|
||||
message: "Choose TYPE of the commit:",
|
||||
choices: ["fix", "feat", "BREAKING CHANGE"],
|
||||
default: nextCommitObject.recommendedNextVersionLevel,
|
||||
},
|
||||
{
|
||||
type: "input",
|
||||
name: "commitScope",
|
||||
message: "What is the SCOPE of the commit:",
|
||||
default: nextCommitObject.recommendedNextVersionScope,
|
||||
},
|
||||
{
|
||||
type: "input",
|
||||
name: "commitDescription",
|
||||
message: "What is the DESCRIPTION of the commit?",
|
||||
default: nextCommitObject.recommendedNextVersionMessage,
|
||||
},
|
||||
]);
|
||||
return await commitInteract.runQueue();
|
||||
}
|
||||
|
||||
function createAnswerBucket(answers: {
|
||||
commitType: string;
|
||||
commitScope: string;
|
||||
commitDescription: string;
|
||||
}): plugins.smartinteract.AnswerBucket {
|
||||
const answerBucket = new plugins.smartinteract.AnswerBucket();
|
||||
for (const [name, value] of Object.entries(answers)) {
|
||||
answerBucket.addAnswer({ name, value });
|
||||
}
|
||||
return answerBucket;
|
||||
}
|
||||
|
||||
async function runChangelogStep(
|
||||
workflow: IResolvedCommitWorkflow,
|
||||
answerBucket: plugins.smartinteract.AnswerBucket,
|
||||
nextCommitObject: any,
|
||||
): Promise<void> {
|
||||
await appendPendingChangelogEntry(
|
||||
plugins.path.join(paths.cwd, workflow.changelogFile),
|
||||
workflow.changelogSection,
|
||||
{
|
||||
type: answerBucket.getAnswerFor("commitType"),
|
||||
scope: answerBucket.getAnswerFor("commitScope"),
|
||||
message: answerBucket.getAnswerFor("commitDescription"),
|
||||
details: nextCommitObject.recommendedNextVersionDetails || [],
|
||||
},
|
||||
);
|
||||
logger.log("success", `Updated ${workflow.changelogFile} pending section.`);
|
||||
}
|
||||
|
||||
async function runCommitStep(
|
||||
smartshellInstance: plugins.smartshell.Smartshell,
|
||||
answerBucket: plugins.smartinteract.AnswerBucket,
|
||||
): Promise<void> {
|
||||
ui.printHeader("Creating Semantic Commit");
|
||||
const commitString = createCommitStringFromAnswerBucket(answerBucket);
|
||||
ui.printCommitMessage(commitString);
|
||||
await smartshellInstance.exec("git add -A");
|
||||
const result = await smartshellInstance.exec(`git commit -m ${shellQuote(commitString)}`);
|
||||
if (result.exitCode !== 0) {
|
||||
logger.log("error", "git commit failed.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function runPushStep(
|
||||
smartshellInstance: plugins.smartshell.Smartshell,
|
||||
workflow: IResolvedCommitWorkflow,
|
||||
): Promise<void> {
|
||||
const currentBranch = await detectCurrentBranch(smartshellInstance);
|
||||
const followTags = workflow.pushFollowTags ? " --follow-tags" : "";
|
||||
const result = await smartshellInstance.exec(
|
||||
`git push ${workflow.pushRemote} ${currentBranch}${followTags}`,
|
||||
);
|
||||
if (result.exitCode !== 0) {
|
||||
logger.log("error", "git push failed.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function detectCurrentBranch(
|
||||
smartshellInstance: plugins.smartshell.Smartshell,
|
||||
): Promise<string> {
|
||||
const branchResult = await smartshellInstance.exec("git branch --show-current");
|
||||
return branchResult.stdout.trim() || "master";
|
||||
}
|
||||
|
||||
function assertAnalysisComplete(
|
||||
answerBucket: plugins.smartinteract.AnswerBucket | undefined,
|
||||
nextCommitObject: any,
|
||||
): void {
|
||||
if (!answerBucket || !nextCommitObject) {
|
||||
throw new Error("Commit workflow requires analyze before changelog and commit steps.");
|
||||
}
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function printCommitExecutionPlan(workflow: IResolvedCommitWorkflow): void {
|
||||
console.log("");
|
||||
console.log("gitzone commit - resolved workflow");
|
||||
console.log(`confirmation: ${workflow.confirmation}`);
|
||||
console.log(`steps: ${workflow.steps.join(" -> ")}`);
|
||||
console.log(`changelog: ${workflow.changelogFile}#${workflow.changelogSection}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
async function handleRecommend(mode: ICliMode): Promise<void> {
|
||||
const recommendationBuilder = async () => {
|
||||
const aidoc = new plugins.tsdoc.AiDoc();
|
||||
@@ -507,40 +319,27 @@ export function showHelp(mode?: ICliMode): void {
|
||||
printJson({
|
||||
command: "commit",
|
||||
usage: "gitzone commit [recommend] [options]",
|
||||
description:
|
||||
"Creates semantic commits or emits a read-only recommendation.",
|
||||
description: "Analyzes changes and creates one semantic source commit.",
|
||||
commands: [
|
||||
{
|
||||
name: "recommend",
|
||||
description:
|
||||
"Generate a commit recommendation without mutating the repository",
|
||||
description: "Generate a commit recommendation without mutating the repository",
|
||||
},
|
||||
],
|
||||
flags: [
|
||||
{ flag: "-y, --yes", description: "Auto-accept AI recommendations" },
|
||||
{ flag: "-p, --push", description: "Push to origin after commit" },
|
||||
{ flag: "-t, --test", description: "Run tests before the commit flow" },
|
||||
{
|
||||
flag: "-b, --build",
|
||||
description: "Run the build after the commit flow",
|
||||
},
|
||||
{
|
||||
flag: "-r, --release",
|
||||
description: "Publish to configured registries after push",
|
||||
},
|
||||
{
|
||||
flag: "--format",
|
||||
description: "Run gitzone format before committing",
|
||||
},
|
||||
{
|
||||
flag: "--json",
|
||||
description: "Emit JSON for `commit recommend` only",
|
||||
},
|
||||
{ flag: "-y, --yes", description: "Auto-accept safe AI recommendations" },
|
||||
{ flag: "-p, --push", description: "Push to origin after committing" },
|
||||
{ flag: "-t, --test", description: "Run tests as part of the commit workflow" },
|
||||
{ flag: "-b, --build", description: "Run build as part of the commit workflow" },
|
||||
{ flag: "-f, --format", description: "Run gitzone format before committing" },
|
||||
{ flag: "--plan", description: "Show resolved workflow without mutating files" },
|
||||
{ flag: "--json", description: "Emit JSON for `commit recommend` only" },
|
||||
],
|
||||
examples: [
|
||||
"gitzone commit recommend --json",
|
||||
"gitzone commit -y",
|
||||
"gitzone commit -ypbr",
|
||||
"gitzone commit -ytbp",
|
||||
"gitzone release",
|
||||
],
|
||||
});
|
||||
return;
|
||||
@@ -549,25 +348,24 @@ export function showHelp(mode?: ICliMode): void {
|
||||
console.log("");
|
||||
console.log("Usage: gitzone commit [recommend] [options]");
|
||||
console.log("");
|
||||
console.log("Creates one semantic source commit. It does not version, tag, or publish.");
|
||||
console.log("");
|
||||
console.log("Commands:");
|
||||
console.log(
|
||||
" recommend Generate a commit recommendation without mutating the repository",
|
||||
);
|
||||
console.log(" recommend Generate a commit recommendation without mutating the repository");
|
||||
console.log("");
|
||||
console.log("Flags:");
|
||||
console.log(" -y, --yes Auto-accept AI recommendations");
|
||||
console.log(" -p, --push Push to origin after commit");
|
||||
console.log(" -t, --test Run tests before the commit flow");
|
||||
console.log(" -b, --build Run the build after the commit flow");
|
||||
console.log(
|
||||
" -r, --release Publish to configured registries after push",
|
||||
);
|
||||
console.log(" --format Run gitzone format before committing");
|
||||
console.log(" -y, --yes Auto-accept safe AI recommendations");
|
||||
console.log(" -p, --push Push after commit");
|
||||
console.log(" -t, --test Run tests in the configured order");
|
||||
console.log(" -b, --build Run build in the configured order");
|
||||
console.log(" -f, --format Run gitzone format before committing");
|
||||
console.log(" --plan Show resolved workflow without mutating files");
|
||||
console.log(" --json Emit JSON for `commit recommend` only");
|
||||
console.log("");
|
||||
console.log("Examples:");
|
||||
console.log(" gitzone commit recommend --json");
|
||||
console.log(" gitzone commit -y");
|
||||
console.log(" gitzone commit -ypbr");
|
||||
console.log(" gitzone commit -ytbp");
|
||||
console.log(" gitzone release");
|
||||
console.log("");
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export async function detectProjectType(): Promise<ProjectType> {
|
||||
* @param versionType Type of version bump
|
||||
* @returns New version string
|
||||
*/
|
||||
function calculateNewVersion(currentVersion: string, versionType: VersionType): string {
|
||||
export function calculateNewVersion(currentVersion: string, versionType: VersionType): string {
|
||||
const versionMatch = currentVersion.match(/^(\d+)\.(\d+)\.(\d+)/);
|
||||
|
||||
if (!versionMatch) {
|
||||
@@ -95,7 +95,7 @@ function calculateNewVersion(currentVersion: string, versionType: VersionType):
|
||||
* @param projectType The project type to determine which file to read
|
||||
* @returns The current version string
|
||||
*/
|
||||
async function readCurrentVersion(projectType: ProjectType): Promise<string> {
|
||||
export async function readCurrentVersion(projectType: ProjectType): Promise<string> {
|
||||
if (projectType === 'npm' || projectType === 'both') {
|
||||
const packageJsonPath = plugins.path.join(paths.cwd, 'package.json');
|
||||
const content = (await plugins.smartfs
|
||||
@@ -128,7 +128,7 @@ async function readCurrentVersion(projectType: ProjectType): Promise<string> {
|
||||
* @param filePath Path to the JSON file
|
||||
* @param newVersion The new version to write
|
||||
*/
|
||||
async function updateVersionFile(filePath: string, newVersion: string): Promise<void> {
|
||||
export async function updateVersionFile(filePath: string, newVersion: string): Promise<void> {
|
||||
const content = (await plugins.smartfs
|
||||
.file(filePath)
|
||||
.encoding('utf8')
|
||||
@@ -141,6 +141,30 @@ async function updateVersionFile(filePath: string, newVersion: string): Promise<
|
||||
.write(JSON.stringify(config, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates project version files without creating commits or tags.
|
||||
*/
|
||||
export async function updateProjectVersionFiles(
|
||||
projectType: ProjectType,
|
||||
newVersion: string,
|
||||
): Promise<string[]> {
|
||||
const filesToUpdate: string[] = [];
|
||||
const packageJsonPath = plugins.path.join(paths.cwd, 'package.json');
|
||||
const denoJsonPath = plugins.path.join(paths.cwd, 'deno.json');
|
||||
|
||||
if (projectType === 'npm' || projectType === 'both') {
|
||||
await updateVersionFile(packageJsonPath, newVersion);
|
||||
filesToUpdate.push('package.json');
|
||||
}
|
||||
|
||||
if (projectType === 'deno' || projectType === 'both') {
|
||||
await updateVersionFile(denoJsonPath, newVersion);
|
||||
filesToUpdate.push('deno.json');
|
||||
}
|
||||
|
||||
return filesToUpdate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bumps the project version based on project type
|
||||
* Handles npm-only, deno-only, and dual projects with unified logic
|
||||
@@ -182,19 +206,7 @@ export async function bumpProjectVersion(
|
||||
logger.log('info', `Bumping version: ${currentVersion} → ${newVersion}`);
|
||||
|
||||
// 3. Determine which files to update
|
||||
const filesToUpdate: string[] = [];
|
||||
const packageJsonPath = plugins.path.join(paths.cwd, 'package.json');
|
||||
const denoJsonPath = plugins.path.join(paths.cwd, 'deno.json');
|
||||
|
||||
if (projectType === 'npm' || projectType === 'both') {
|
||||
await updateVersionFile(packageJsonPath, newVersion);
|
||||
filesToUpdate.push('package.json');
|
||||
}
|
||||
|
||||
if (projectType === 'deno' || projectType === 'both') {
|
||||
await updateVersionFile(denoJsonPath, newVersion);
|
||||
filesToUpdate.push('deno.json');
|
||||
}
|
||||
const filesToUpdate = await updateProjectVersionFiles(projectType, newVersion);
|
||||
|
||||
// 4. Stage all updated files
|
||||
await smartshellInstance.exec(`git add ${filesToUpdate.join(' ')}`);
|
||||
|
||||
@@ -10,7 +10,7 @@ interface ICommitSummary {
|
||||
commitType: string;
|
||||
commitScope: string;
|
||||
commitMessage: string;
|
||||
newVersion: string;
|
||||
newVersion?: string;
|
||||
commitSha?: string;
|
||||
pushed: boolean;
|
||||
repoUrl?: string;
|
||||
@@ -197,9 +197,14 @@ export function printSummary(summary: ICommitSummary): void {
|
||||
`Branch: 🌿 ${summary.branch}`,
|
||||
`Commit Type: ${getCommitTypeEmoji(summary.commitType)}`,
|
||||
`Scope: 📍 ${summary.commitScope}`,
|
||||
`New Version: 🏷️ v${summary.newVersion}`,
|
||||
];
|
||||
|
||||
if (summary.newVersion) {
|
||||
lines.push(`New Version: 🏷️ v${summary.newVersion}`);
|
||||
} else {
|
||||
lines.push(`Version: ⊘ Not bumped`);
|
||||
}
|
||||
|
||||
if (summary.commitSha) {
|
||||
lines.push(`Commit SHA: 📌 ${summary.commitSha}`);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as plugins from './mod.plugins.js';
|
||||
export interface ICommitConfig {
|
||||
alwaysTest: boolean;
|
||||
alwaysBuild: boolean;
|
||||
confirmation: 'prompt' | 'auto' | 'plan';
|
||||
steps: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -15,7 +17,7 @@ export class CommitConfig {
|
||||
|
||||
constructor(cwd: string = process.cwd()) {
|
||||
this.cwd = cwd;
|
||||
this.config = { alwaysTest: false, alwaysBuild: false };
|
||||
this.config = { alwaysTest: false, alwaysBuild: false, confirmation: 'prompt', steps: ['analyze', 'changelog', 'commit'] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,9 +36,19 @@ export class CommitConfig {
|
||||
const smartconfigInstance = new plugins.smartconfig.Smartconfig(this.cwd);
|
||||
const gitzoneConfig = smartconfigInstance.dataFor<any>('@git.zone/cli', {});
|
||||
|
||||
const alwaysTest = gitzoneConfig?.commit?.alwaysTest ?? false;
|
||||
const alwaysBuild = gitzoneConfig?.commit?.alwaysBuild ?? false;
|
||||
this.config = {
|
||||
alwaysTest: gitzoneConfig?.commit?.alwaysTest ?? false,
|
||||
alwaysBuild: gitzoneConfig?.commit?.alwaysBuild ?? false,
|
||||
alwaysTest,
|
||||
alwaysBuild,
|
||||
confirmation: gitzoneConfig?.commit?.confirmation ?? 'prompt',
|
||||
steps: gitzoneConfig?.commit?.steps || [
|
||||
'analyze',
|
||||
...(alwaysTest ? ['test'] : []),
|
||||
...(alwaysBuild ? ['build'] : []),
|
||||
'changelog',
|
||||
'commit',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,6 +78,8 @@ export class CommitConfig {
|
||||
// Update commit settings
|
||||
smartconfigData['@git.zone/cli'].commit.alwaysTest = this.config.alwaysTest;
|
||||
smartconfigData['@git.zone/cli'].commit.alwaysBuild = this.config.alwaysBuild;
|
||||
smartconfigData['@git.zone/cli'].commit.confirmation = this.config.confirmation;
|
||||
smartconfigData['@git.zone/cli'].commit.steps = this.config.steps;
|
||||
|
||||
// Write back to file
|
||||
await plugins.smartfs
|
||||
@@ -101,4 +115,20 @@ export class CommitConfig {
|
||||
public setAlwaysBuild(value: boolean): void {
|
||||
this.config.alwaysBuild = value;
|
||||
}
|
||||
|
||||
public getConfirmation(): 'prompt' | 'auto' | 'plan' {
|
||||
return this.config.confirmation;
|
||||
}
|
||||
|
||||
public setConfirmation(value: 'prompt' | 'auto' | 'plan'): void {
|
||||
this.config.confirmation = value;
|
||||
}
|
||||
|
||||
public getSteps(): string[] {
|
||||
return [...this.config.steps];
|
||||
}
|
||||
|
||||
public setSteps(steps: string[]): void {
|
||||
this.config.steps = [...steps];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,13 +35,11 @@ export class ReleaseConfig {
|
||||
public async load(): Promise<void> {
|
||||
const smartconfigInstance = new plugins.smartconfig.Smartconfig(this.cwd);
|
||||
const gitzoneConfig = smartconfigInstance.dataFor<any>('@git.zone/cli', {});
|
||||
|
||||
// Also check szci for backward compatibility
|
||||
const szciConfig = smartconfigInstance.dataFor<any>('@ship.zone/szci', {});
|
||||
const npmTarget = gitzoneConfig?.release?.targets?.npm || {};
|
||||
|
||||
this.config = {
|
||||
registries: gitzoneConfig?.release?.registries || [],
|
||||
accessLevel: gitzoneConfig?.release?.accessLevel || szciConfig?.npmAccessLevel || 'public',
|
||||
registries: npmTarget.registries || [],
|
||||
accessLevel: npmTarget.accessLevel || 'public',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,9 +66,18 @@ export class ReleaseConfig {
|
||||
smartconfigData['@git.zone/cli'].release = {};
|
||||
}
|
||||
|
||||
if (!smartconfigData['@git.zone/cli'].release.targets) {
|
||||
smartconfigData['@git.zone/cli'].release.targets = {};
|
||||
}
|
||||
|
||||
if (!smartconfigData['@git.zone/cli'].release.targets.npm) {
|
||||
smartconfigData['@git.zone/cli'].release.targets.npm = {};
|
||||
}
|
||||
|
||||
// Update registries and accessLevel
|
||||
smartconfigData['@git.zone/cli'].release.registries = this.config.registries;
|
||||
smartconfigData['@git.zone/cli'].release.accessLevel = this.config.accessLevel;
|
||||
smartconfigData['@git.zone/cli'].release.targets.npm.enabled = this.config.registries.length > 0;
|
||||
smartconfigData['@git.zone/cli'].release.targets.npm.registries = this.config.registries;
|
||||
smartconfigData['@git.zone/cli'].release.targets.npm.accessLevel = this.config.accessLevel;
|
||||
|
||||
// Write back to file
|
||||
await plugins.smartfs
|
||||
|
||||
+89
-28
@@ -1,4 +1,4 @@
|
||||
// gitzone config - manage release registry configuration
|
||||
// gitzone config - manage CLI smartconfig configuration
|
||||
|
||||
import * as plugins from "./mod.plugins.js";
|
||||
import { ReleaseConfig } from "./classes.releaseconfig.js";
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
unsetCliConfigValueInData,
|
||||
writeSmartconfigFile,
|
||||
} from "../helpers.smartconfig.js";
|
||||
import {
|
||||
CURRENT_GITZONE_CLI_SCHEMA_VERSION,
|
||||
migrateSmartconfigData,
|
||||
} from "../helpers.smartconfigmigrations.js";
|
||||
|
||||
export { ReleaseConfig, CommitConfig };
|
||||
|
||||
@@ -99,6 +103,9 @@ export const run = async (argvArg: any) => {
|
||||
case "services":
|
||||
await handleServices(mode);
|
||||
break;
|
||||
case "migrate":
|
||||
await handleMigrate(value, mode);
|
||||
break;
|
||||
case "get":
|
||||
await handleGet(value, mode);
|
||||
break;
|
||||
@@ -138,10 +145,11 @@ async function handleInteractiveMenu(): Promise<void> {
|
||||
default: "show",
|
||||
choices: [
|
||||
{ name: "Show current configuration", value: "show" },
|
||||
{ name: "Add a registry", value: "add" },
|
||||
{ name: "Remove a registry", value: "remove" },
|
||||
{ name: "Clear all registries", value: "clear" },
|
||||
{ name: "Add an npm target registry", value: "add" },
|
||||
{ name: "Remove an npm target registry", value: "remove" },
|
||||
{ name: "Clear npm target registries", value: "clear" },
|
||||
{ name: "Set access level (public/private)", value: "access" },
|
||||
{ name: "Migrate smartconfig schema", value: "migrate" },
|
||||
{ name: "Configure commit options", value: "commit" },
|
||||
{ name: "Configure services", value: "services" },
|
||||
{ name: "Show help", value: "help" },
|
||||
@@ -166,6 +174,9 @@ async function handleInteractiveMenu(): Promise<void> {
|
||||
case "access":
|
||||
await handleAccessLevel(undefined, defaultCliMode);
|
||||
break;
|
||||
case "migrate":
|
||||
await handleMigrate(undefined, defaultCliMode);
|
||||
break;
|
||||
case "commit":
|
||||
await handleCommit(undefined, undefined, defaultCliMode);
|
||||
break;
|
||||
@@ -197,7 +208,7 @@ async function handleShow(mode: ICliMode): Promise<void> {
|
||||
"╭─────────────────────────────────────────────────────────────╮",
|
||||
);
|
||||
console.log(
|
||||
"│ Release Configuration │",
|
||||
"│ Release NPM Target Configuration │",
|
||||
);
|
||||
console.log(
|
||||
"╰─────────────────────────────────────────────────────────────╯",
|
||||
@@ -209,12 +220,12 @@ async function handleShow(mode: ICliMode): Promise<void> {
|
||||
console.log("");
|
||||
|
||||
if (registries.length === 0) {
|
||||
plugins.logger.log("info", "No release registries configured.");
|
||||
plugins.logger.log("info", "No npm target registries configured.");
|
||||
console.log("");
|
||||
console.log(" Run `gitzone config add <registry-url>` to add one.");
|
||||
console.log("");
|
||||
} else {
|
||||
plugins.logger.log("info", `Configured registries (${registries.length}):`);
|
||||
plugins.logger.log("info", `Configured npm target registries (${registries.length}):`);
|
||||
console.log("");
|
||||
registries.forEach((url, index) => {
|
||||
console.log(` ${index + 1}. ${url}`);
|
||||
@@ -224,7 +235,7 @@ async function handleShow(mode: ICliMode): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a registry URL
|
||||
* Add an npm target registry URL
|
||||
*/
|
||||
async function handleAdd(
|
||||
url: string | undefined,
|
||||
@@ -240,7 +251,7 @@ async function handleAdd(
|
||||
const response = await interactInstance.askQuestion({
|
||||
type: "input",
|
||||
name: "registryUrl",
|
||||
message: "Enter registry URL:",
|
||||
message: "Enter npm target registry URL:",
|
||||
default: "https://registry.npmjs.org",
|
||||
validate: (input: string) => {
|
||||
return !!(input && input.trim() !== "");
|
||||
@@ -263,7 +274,7 @@ async function handleAdd(
|
||||
});
|
||||
return;
|
||||
}
|
||||
plugins.logger.log("success", `Added registry: ${url}`);
|
||||
plugins.logger.log("success", `Added npm target registry: ${url}`);
|
||||
await formatSmartconfigWithDiff(mode);
|
||||
} else {
|
||||
plugins.logger.log("warn", `Registry already exists: ${url}`);
|
||||
@@ -271,7 +282,7 @@ async function handleAdd(
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a registry URL
|
||||
* Remove an npm target registry URL
|
||||
*/
|
||||
async function handleRemove(
|
||||
url: string | undefined,
|
||||
@@ -281,7 +292,7 @@ async function handleRemove(
|
||||
const registries = config.getRegistries();
|
||||
|
||||
if (registries.length === 0) {
|
||||
plugins.logger.log("warn", "No registries configured to remove.");
|
||||
plugins.logger.log("warn", "No npm target registries configured to remove.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -295,7 +306,7 @@ async function handleRemove(
|
||||
const response = await interactInstance.askQuestion({
|
||||
type: "list",
|
||||
name: "registryUrl",
|
||||
message: "Select registry to remove:",
|
||||
message: "Select npm target registry to remove:",
|
||||
choices: registries,
|
||||
default: registries[0],
|
||||
});
|
||||
@@ -315,7 +326,7 @@ async function handleRemove(
|
||||
});
|
||||
return;
|
||||
}
|
||||
plugins.logger.log("success", `Removed registry: ${url}`);
|
||||
plugins.logger.log("success", `Removed npm target registry: ${url}`);
|
||||
await formatSmartconfigWithDiff(mode);
|
||||
} else {
|
||||
plugins.logger.log("warn", `Registry not found: ${url}`);
|
||||
@@ -323,20 +334,20 @@ async function handleRemove(
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all registries
|
||||
* Clear all npm target registries
|
||||
*/
|
||||
async function handleClear(mode: ICliMode): Promise<void> {
|
||||
const config = await ReleaseConfig.fromCwd();
|
||||
|
||||
if (!config.hasRegistries()) {
|
||||
plugins.logger.log("info", "No registries to clear.");
|
||||
plugins.logger.log("info", "No npm target registries to clear.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm before clearing
|
||||
const confirmed = mode.interactive
|
||||
? await plugins.smartinteract.SmartInteract.getCliConfirmation(
|
||||
"Clear all configured registries?",
|
||||
"Clear all configured npm target registries?",
|
||||
false,
|
||||
)
|
||||
: true;
|
||||
@@ -348,7 +359,7 @@ async function handleClear(mode: ICliMode): Promise<void> {
|
||||
printJson({ ok: true, action: "clear", registries: [] });
|
||||
return;
|
||||
}
|
||||
plugins.logger.log("success", "All registries cleared.");
|
||||
plugins.logger.log("success", "All npm target registries cleared.");
|
||||
await formatSmartconfigWithDiff(mode);
|
||||
} else {
|
||||
plugins.logger.log("info", "Operation cancelled.");
|
||||
@@ -473,6 +484,7 @@ async function handleCommitInteractive(config: CommitConfig): Promise<void> {
|
||||
const selected = (response as any).value || [];
|
||||
config.setAlwaysTest(selected.includes("alwaysTest"));
|
||||
config.setAlwaysBuild(selected.includes("alwaysBuild"));
|
||||
syncCommitStepsFromBooleans(config);
|
||||
await config.save();
|
||||
|
||||
plugins.logger.log("success", "Commit configuration updated");
|
||||
@@ -496,6 +508,7 @@ async function handleCommitSetting(
|
||||
} else if (setting === "alwaysBuild") {
|
||||
config.setAlwaysBuild(boolValue);
|
||||
}
|
||||
syncCommitStepsFromBooleans(config);
|
||||
|
||||
await config.save();
|
||||
if (mode.json) {
|
||||
@@ -506,6 +519,16 @@ async function handleCommitSetting(
|
||||
await formatSmartconfigWithDiff(mode);
|
||||
}
|
||||
|
||||
function syncCommitStepsFromBooleans(config: CommitConfig): void {
|
||||
config.setSteps([
|
||||
"analyze",
|
||||
...(config.getAlwaysTest() ? ["test"] : []),
|
||||
...(config.getAlwaysBuild() ? ["build"] : []),
|
||||
"changelog",
|
||||
"commit",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show help for commit subcommand
|
||||
*/
|
||||
@@ -636,6 +659,38 @@ async function handleUnset(
|
||||
plugins.logger.log("success", `Unset ${configPath}`);
|
||||
}
|
||||
|
||||
async function handleMigrate(
|
||||
rawTargetVersion: string | undefined,
|
||||
mode: ICliMode,
|
||||
): Promise<void> {
|
||||
const targetVersion = rawTargetVersion
|
||||
? Number(rawTargetVersion)
|
||||
: CURRENT_GITZONE_CLI_SCHEMA_VERSION;
|
||||
if (!Number.isInteger(targetVersion) || targetVersion < 1) {
|
||||
throw new Error("Migration target version must be a positive integer");
|
||||
}
|
||||
|
||||
const smartconfigData = await readSmartconfigFile();
|
||||
const result = migrateSmartconfigData(smartconfigData, targetVersion);
|
||||
if (result.migrated) {
|
||||
await writeSmartconfigFile(smartconfigData);
|
||||
}
|
||||
|
||||
if (mode.json) {
|
||||
printJson({ ok: true, action: "migrate", ...result });
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.migrated) {
|
||||
plugins.logger.log(
|
||||
"success",
|
||||
`Migrated .smartconfig.json from schema v${result.fromVersion} to v${result.toVersion}`,
|
||||
);
|
||||
} else {
|
||||
plugins.logger.log("info", `.smartconfig.json already at schema v${result.toVersion}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseConfigValue(rawValue: string): any {
|
||||
const trimmedValue = rawValue.trim();
|
||||
if (trimmedValue === "true") {
|
||||
@@ -676,21 +731,25 @@ export function showHelp(mode?: ICliMode): void {
|
||||
{ name: "get <path>", description: "Read a single config value" },
|
||||
{ name: "set <path> <value>", description: "Write a config value" },
|
||||
{ name: "unset <path>", description: "Delete a config value" },
|
||||
{ name: "add [url]", description: "Add a release registry" },
|
||||
{ name: "remove [url]", description: "Remove a release registry" },
|
||||
{ name: "clear", description: "Clear all release registries" },
|
||||
{ name: "add [url]", description: "Add an npm release target registry" },
|
||||
{ name: "remove [url]", description: "Remove an npm release target registry" },
|
||||
{ name: "clear", description: "Clear npm release target registries" },
|
||||
{
|
||||
name: "access [public|private]",
|
||||
description: "Set npm publish access level",
|
||||
description: "Set npm target publish access level",
|
||||
},
|
||||
{
|
||||
name: "commit <setting> <value>",
|
||||
description: "Set commit defaults",
|
||||
},
|
||||
{
|
||||
name: "migrate [version]",
|
||||
description: "Run version-targeted .smartconfig.json migrations",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
"gitzone config show --json",
|
||||
"gitzone config get release.accessLevel",
|
||||
"gitzone config get release.targets.npm.accessLevel",
|
||||
"gitzone config set cli.interactive false",
|
||||
"gitzone config set cli.output json",
|
||||
],
|
||||
@@ -708,13 +767,14 @@ export function showHelp(mode?: ICliMode): void {
|
||||
console.log(" get <path> Read a single config value");
|
||||
console.log(" set <path> <value> Write a config value");
|
||||
console.log(" unset <path> Delete a config value");
|
||||
console.log(" add [url] Add a registry URL");
|
||||
console.log(" remove [url] Remove a registry URL");
|
||||
console.log(" clear Clear all registries");
|
||||
console.log(" add [url] Add an npm target registry URL");
|
||||
console.log(" remove [url] Remove an npm target registry URL");
|
||||
console.log(" clear Clear npm target registries");
|
||||
console.log(
|
||||
" access [public|private] Set npm access level for publishing",
|
||||
" access [public|private] Set npm target access level for publishing",
|
||||
);
|
||||
console.log(" commit [setting] [value] Configure commit options");
|
||||
console.log(" migrate [version] Run version-targeted smartconfig migrations");
|
||||
console.log(
|
||||
" services Configure which services are enabled",
|
||||
);
|
||||
@@ -722,7 +782,7 @@ export function showHelp(mode?: ICliMode): void {
|
||||
console.log("Examples:");
|
||||
console.log(" gitzone config show");
|
||||
console.log(" gitzone config show --json");
|
||||
console.log(" gitzone config get release.accessLevel");
|
||||
console.log(" gitzone config get release.targets.npm.accessLevel");
|
||||
console.log(" gitzone config set cli.interactive false");
|
||||
console.log(" gitzone config set cli.output json");
|
||||
console.log(" gitzone config unset cli.output");
|
||||
@@ -732,6 +792,7 @@ export function showHelp(mode?: ICliMode): void {
|
||||
console.log(" gitzone config clear");
|
||||
console.log(" gitzone config access public");
|
||||
console.log(" gitzone config access private");
|
||||
console.log(" gitzone config migrate 2");
|
||||
console.log(" gitzone config commit # Interactive");
|
||||
console.log(" gitzone config commit alwaysTest true");
|
||||
console.log(" gitzone config services # Interactive");
|
||||
|
||||
@@ -2,65 +2,7 @@ import { BaseFormatter } from "../classes.baseformatter.js";
|
||||
import type { IPlannedChange } from "../interfaces.format.js";
|
||||
import * as plugins from "../mod.plugins.js";
|
||||
import { logger, logVerbose } from "../../gitzone.logging.js";
|
||||
|
||||
/**
|
||||
* Migrates .smartconfig.json from old namespace keys to new package-scoped keys
|
||||
*/
|
||||
const migrateNamespaceKeys = (smartconfigJson: any): boolean => {
|
||||
let migrated = false;
|
||||
const migrations = [
|
||||
{ oldKey: "gitzone", newKey: "@git.zone/cli" },
|
||||
{ oldKey: "tsdoc", newKey: "@git.zone/tsdoc" },
|
||||
{ oldKey: "npmdocker", newKey: "@git.zone/tsdocker" },
|
||||
{ oldKey: "npmci", newKey: "@ship.zone/szci" },
|
||||
{ oldKey: "szci", newKey: "@ship.zone/szci" },
|
||||
];
|
||||
for (const { oldKey, newKey } of migrations) {
|
||||
if (smartconfigJson[oldKey]) {
|
||||
if (!smartconfigJson[newKey]) {
|
||||
smartconfigJson[newKey] = smartconfigJson[oldKey];
|
||||
} else {
|
||||
smartconfigJson[newKey] = {
|
||||
...smartconfigJson[oldKey],
|
||||
...smartconfigJson[newKey],
|
||||
};
|
||||
}
|
||||
delete smartconfigJson[oldKey];
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
return migrated;
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrates npmAccessLevel from @ship.zone/szci to @git.zone/cli.release.accessLevel
|
||||
*/
|
||||
const migrateAccessLevel = (smartconfigJson: any): boolean => {
|
||||
const szciConfig = smartconfigJson["@ship.zone/szci"];
|
||||
|
||||
if (!szciConfig?.npmAccessLevel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const gitzoneConfig = smartconfigJson["@git.zone/cli"] || {};
|
||||
if (gitzoneConfig?.release?.accessLevel) {
|
||||
delete szciConfig.npmAccessLevel;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!smartconfigJson["@git.zone/cli"]) {
|
||||
smartconfigJson["@git.zone/cli"] = {};
|
||||
}
|
||||
if (!smartconfigJson["@git.zone/cli"].release) {
|
||||
smartconfigJson["@git.zone/cli"].release = {};
|
||||
}
|
||||
|
||||
smartconfigJson["@git.zone/cli"].release.accessLevel =
|
||||
szciConfig.npmAccessLevel;
|
||||
delete szciConfig.npmAccessLevel;
|
||||
|
||||
return true;
|
||||
};
|
||||
import { migrateSmartconfigData } from "../../helpers.smartconfigmigrations.js";
|
||||
|
||||
const CONFIG_FILE = ".smartconfig.json";
|
||||
|
||||
@@ -88,9 +30,7 @@ export class SmartconfigFormatter extends BaseFormatter {
|
||||
|
||||
const smartconfigJson = JSON.parse(currentContent);
|
||||
|
||||
// Apply key migrations
|
||||
migrateNamespaceKeys(smartconfigJson);
|
||||
migrateAccessLevel(smartconfigJson);
|
||||
migrateSmartconfigData(smartconfigJson);
|
||||
|
||||
// Ensure namespaces exist
|
||||
if (!smartconfigJson["@git.zone/cli"]) {
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import * as plugins from "./mod.plugins.js";
|
||||
import * as paths from "../paths.js";
|
||||
import { logger } from "../gitzone.logging.js";
|
||||
import type { ICliMode } from "../helpers.climode.js";
|
||||
import { getCliMode, printJson } from "../helpers.climode.js";
|
||||
import {
|
||||
inferVersionTypeFromPending,
|
||||
movePendingToVersion,
|
||||
readPendingChangelog,
|
||||
} from "../helpers.changelog.js";
|
||||
import {
|
||||
resolveReleaseWorkflow,
|
||||
type IResolvedReleaseWorkflow,
|
||||
} from "../helpers.workflow.js";
|
||||
import * as commitHelpers from "../mod_commit/mod.helpers.js";
|
||||
|
||||
type TTargetStatus = "success" | "already-published" | "skipped" | "failed";
|
||||
|
||||
interface ITargetResult {
|
||||
target: string;
|
||||
status: TTargetStatus;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const run = async (argvArg: any) => {
|
||||
const mode = await getCliMode(argvArg);
|
||||
const subcommand = argvArg._?.[1];
|
||||
|
||||
if (mode.help || subcommand === "help") {
|
||||
showHelp(mode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode.json) {
|
||||
printJson({
|
||||
ok: false,
|
||||
error: "JSON output is not supported for mutating release workflows yet. Use `gitzone release --plan` for a human-readable plan.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const workflow = await resolveReleaseWorkflow(argvArg);
|
||||
printReleasePlan(workflow);
|
||||
if (workflow.confirmation === "plan") {
|
||||
return;
|
||||
}
|
||||
|
||||
const smartshellInstance = new plugins.smartshell.Smartshell({
|
||||
executor: "bash",
|
||||
sourceFilePaths: [],
|
||||
});
|
||||
|
||||
const pending = await readPendingChangelog(
|
||||
plugins.path.join(paths.cwd, workflow.changelogFile),
|
||||
workflow.changelogPendingSection,
|
||||
);
|
||||
if (pending.isEmpty && !argvArg["allow-empty"] && !argvArg.allowEmpty) {
|
||||
logger.log("error", "No pending changelog entries. Nothing to release.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const versionType = resolveVersionType(argvArg, pending.block);
|
||||
const projectType = await commitHelpers.detectProjectType();
|
||||
const currentVersion = await commitHelpers.readCurrentVersion(projectType);
|
||||
const plannedVersion = commitHelpers.calculateNewVersion(currentVersion, versionType);
|
||||
|
||||
if (workflow.confirmation === "prompt") {
|
||||
if (!mode.interactive) {
|
||||
throw new Error("Release confirmation requires an interactive terminal. Use `-y` or set release.confirmation to `auto`.");
|
||||
}
|
||||
const confirmed = await plugins.smartinteract.SmartInteract.getCliConfirmation(
|
||||
`Release v${plannedVersion} (${versionType}) now?`,
|
||||
true,
|
||||
);
|
||||
if (!confirmed) {
|
||||
logger.log("info", "Release cancelled.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let newVersion = plannedVersion;
|
||||
const gitResults: ITargetResult[] = [];
|
||||
const npmResults: ITargetResult[] = [];
|
||||
const dockerResults: ITargetResult[] = [];
|
||||
|
||||
if (workflow.requireCleanTree) {
|
||||
await verifyCleanTree(smartshellInstance, "Working tree is not clean. Commit or stash changes before releasing.");
|
||||
}
|
||||
if (workflow.runTests) {
|
||||
await runCommandStep(smartshellInstance, "Running tests", workflow.testCommand);
|
||||
}
|
||||
|
||||
newVersion = await runVersionStep(projectType, versionType);
|
||||
await runChangelogStep(workflow, newVersion);
|
||||
await runReleaseCommitStep(smartshellInstance, newVersion);
|
||||
await runTagStep(smartshellInstance, newVersion);
|
||||
|
||||
if (workflow.runBuild) {
|
||||
await runCommandStep(smartshellInstance, "Running release build", workflow.buildCommand);
|
||||
await verifyCleanTree(smartshellInstance, "Build produced uncommitted changes. Aborting release.");
|
||||
}
|
||||
|
||||
if (workflow.targets.includes("git")) {
|
||||
gitResults.push(...(await runGitTarget(smartshellInstance, workflow)));
|
||||
}
|
||||
if (workflow.targets.includes("npm")) {
|
||||
npmResults.push(...(await runNpmTarget(smartshellInstance, workflow)));
|
||||
}
|
||||
if (workflow.targets.includes("docker")) {
|
||||
dockerResults.push(...(await runDockerTarget(smartshellInstance, workflow, newVersion)));
|
||||
}
|
||||
|
||||
printReleaseSummary(newVersion, gitResults, npmResults, dockerResults);
|
||||
if ([...gitResults, ...npmResults, ...dockerResults].some((result) => result.status === "failed")) {
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
function resolveVersionType(argvArg: any, pendingBlock: string): commitHelpers.VersionType {
|
||||
if (argvArg.major) return "major";
|
||||
if (argvArg.minor) return "minor";
|
||||
if (argvArg.patch) return "patch";
|
||||
return inferVersionTypeFromPending(pendingBlock);
|
||||
}
|
||||
|
||||
async function runCommandStep(
|
||||
smartshellInstance: plugins.smartshell.Smartshell,
|
||||
label: string,
|
||||
command: string,
|
||||
): Promise<void> {
|
||||
console.log(`\n${label}`);
|
||||
const result = await smartshellInstance.exec(command);
|
||||
if (result.exitCode !== 0) {
|
||||
logger.log("error", `${label} failed. Aborting release.`);
|
||||
process.exit(1);
|
||||
}
|
||||
logger.log("success", `${label} passed.`);
|
||||
}
|
||||
|
||||
async function verifyCleanTree(
|
||||
smartshellInstance: plugins.smartshell.Smartshell,
|
||||
errorMessage: string,
|
||||
): Promise<void> {
|
||||
const statusResult = await smartshellInstance.exec("git status --porcelain");
|
||||
if (statusResult.stdout.trim() !== "") {
|
||||
logger.log("error", errorMessage);
|
||||
console.log(statusResult.stdout);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function runVersionStep(
|
||||
projectType: commitHelpers.ProjectType,
|
||||
versionType: commitHelpers.VersionType,
|
||||
): Promise<string> {
|
||||
const currentVersion = await commitHelpers.readCurrentVersion(projectType);
|
||||
const newVersion = commitHelpers.calculateNewVersion(currentVersion, versionType);
|
||||
logger.log("info", `Bumping version: ${currentVersion} -> ${newVersion}`);
|
||||
|
||||
const commitInfo = new plugins.commitinfo.CommitInfo(paths.cwd, versionType);
|
||||
await commitInfo.writeIntoPotentialDirs();
|
||||
await commitHelpers.updateProjectVersionFiles(projectType, newVersion);
|
||||
return newVersion;
|
||||
}
|
||||
|
||||
async function runChangelogStep(
|
||||
workflow: IResolvedReleaseWorkflow,
|
||||
newVersion: string,
|
||||
): Promise<void> {
|
||||
const dateString = new Date().toISOString().slice(0, 10);
|
||||
await movePendingToVersion(
|
||||
plugins.path.join(paths.cwd, workflow.changelogFile),
|
||||
workflow.changelogPendingSection,
|
||||
workflow.changelogVersionHeading,
|
||||
newVersion,
|
||||
dateString,
|
||||
);
|
||||
}
|
||||
|
||||
async function runReleaseCommitStep(
|
||||
smartshellInstance: plugins.smartshell.Smartshell,
|
||||
newVersion: string,
|
||||
): Promise<void> {
|
||||
await smartshellInstance.exec("git add -A");
|
||||
const result = await smartshellInstance.exec(`git commit -m ${shellQuote(`v${newVersion}`)}`);
|
||||
if (result.exitCode !== 0) {
|
||||
logger.log("error", "Release commit failed.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function runTagStep(
|
||||
smartshellInstance: plugins.smartshell.Smartshell,
|
||||
newVersion: string,
|
||||
): Promise<void> {
|
||||
const result = await smartshellInstance.exec(`git tag v${newVersion} -m ${shellQuote(`v${newVersion}`)}`);
|
||||
if (result.exitCode !== 0) {
|
||||
logger.log("error", "Release tag failed.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function runGitTarget(
|
||||
smartshellInstance: plugins.smartshell.Smartshell,
|
||||
workflow: IResolvedReleaseWorkflow,
|
||||
): Promise<ITargetResult[]> {
|
||||
const currentBranchResult = await smartshellInstance.exec("git branch --show-current");
|
||||
const currentBranch = currentBranchResult.stdout.trim() || "master";
|
||||
const commands: Array<{ target: string; command: string }> = [];
|
||||
if (workflow.pushBranch) {
|
||||
commands.push({
|
||||
target: `${workflow.gitRemote}/${currentBranch}`,
|
||||
command: `git push ${workflow.gitRemote} ${currentBranch}`,
|
||||
});
|
||||
}
|
||||
if (workflow.pushTags) {
|
||||
commands.push({
|
||||
target: `${workflow.gitRemote}/tags`,
|
||||
command: `git push ${workflow.gitRemote} --tags`,
|
||||
});
|
||||
}
|
||||
|
||||
const results: ITargetResult[] = [];
|
||||
for (const { target, command } of commands) {
|
||||
const result = await smartshellInstance.exec(command);
|
||||
results.push({
|
||||
target,
|
||||
status: result.exitCode === 0 ? "success" : "failed",
|
||||
message: result.exitCode === 0 ? undefined : "push failed",
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function runNpmTarget(
|
||||
smartshellInstance: plugins.smartshell.Smartshell,
|
||||
workflow: IResolvedReleaseWorkflow,
|
||||
): Promise<ITargetResult[]> {
|
||||
if (!workflow.npmEnabled) {
|
||||
return [{ target: "npm", status: "skipped", message: "disabled" }];
|
||||
}
|
||||
if (workflow.npmRegistries.length === 0) {
|
||||
return [{ target: "npm", status: "failed", message: "no registries configured" }];
|
||||
}
|
||||
|
||||
const results: ITargetResult[] = [];
|
||||
for (const registry of workflow.npmRegistries) {
|
||||
const command = `pnpm publish --registry=${registry} --access=${workflow.npmAccessLevel}`;
|
||||
const result = await smartshellInstance.exec(command);
|
||||
const output = `${result.stdout || ""}\n${(result as any).stderr || ""}\n${(result as any).combinedOutput || ""}`;
|
||||
if (result.exitCode === 0) {
|
||||
results.push({ target: registry, status: "success" });
|
||||
} else if (isAlreadyPublishedOutput(output) && workflow.npmAlreadyPublished === "success") {
|
||||
results.push({ target: registry, status: "already-published" });
|
||||
} else {
|
||||
results.push({ target: registry, status: "failed", message: firstMeaningfulLine(output) });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function runDockerTarget(
|
||||
smartshellInstance: plugins.smartshell.Smartshell,
|
||||
workflow: IResolvedReleaseWorkflow,
|
||||
newVersion: string,
|
||||
): Promise<ITargetResult[]> {
|
||||
if (!workflow.dockerEnabled) {
|
||||
return [{ target: "docker", status: "skipped", message: "disabled" }];
|
||||
}
|
||||
if (workflow.dockerImages.length === 0) {
|
||||
return [{ target: "docker", status: "failed", message: "no images configured" }];
|
||||
}
|
||||
|
||||
const results: ITargetResult[] = [];
|
||||
for (const imageTemplate of workflow.dockerImages) {
|
||||
const image = imageTemplate.replaceAll("{{version}}", newVersion);
|
||||
const buildResult = await smartshellInstance.exec(`docker build -t ${shellQuote(image)} .`);
|
||||
if (buildResult.exitCode !== 0) {
|
||||
results.push({ target: image, status: "failed", message: "docker build failed" });
|
||||
continue;
|
||||
}
|
||||
const pushResult = await smartshellInstance.exec(`docker push ${shellQuote(image)}`);
|
||||
results.push({
|
||||
target: image,
|
||||
status: pushResult.exitCode === 0 ? "success" : "failed",
|
||||
message: pushResult.exitCode === 0 ? undefined : "docker push failed",
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function isAlreadyPublishedOutput(output: string): boolean {
|
||||
return /previously published versions|cannot publish over|already exists/i.test(output);
|
||||
}
|
||||
|
||||
function firstMeaningfulLine(output: string): string {
|
||||
return output
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0) || "command failed";
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function printReleasePlan(workflow: IResolvedReleaseWorkflow): void {
|
||||
console.log("");
|
||||
console.log("gitzone release - resolved workflow");
|
||||
console.log(`confirmation: ${workflow.confirmation}`);
|
||||
console.log(`plan: ${workflow.plan.join(" -> ")}`);
|
||||
console.log(`targets: ${workflow.targets.length > 0 ? workflow.targets.join(", ") : "none"}`);
|
||||
console.log(`changelog: ${workflow.changelogFile}#${workflow.changelogPendingSection}`);
|
||||
if (workflow.targets.includes("npm")) {
|
||||
console.log(`npm registries: ${workflow.npmRegistries.length > 0 ? workflow.npmRegistries.join(", ") : "none"}`);
|
||||
}
|
||||
if (workflow.targets.includes("docker")) {
|
||||
console.log(`docker images: ${workflow.dockerImages.length > 0 ? workflow.dockerImages.join(", ") : "none"}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
function printReleaseSummary(
|
||||
newVersion: string,
|
||||
gitResults: ITargetResult[],
|
||||
npmResults: ITargetResult[],
|
||||
dockerResults: ITargetResult[],
|
||||
): void {
|
||||
console.log("");
|
||||
console.log(`Release v${newVersion}`);
|
||||
console.log("");
|
||||
|
||||
if (gitResults.length > 0) {
|
||||
console.log("git:");
|
||||
for (const result of gitResults) {
|
||||
console.log(` ${result.target} ${result.status}${result.message ? ` (${result.message})` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (npmResults.length > 0) {
|
||||
console.log("npm:");
|
||||
for (const result of npmResults) {
|
||||
console.log(` ${result.target} ${result.status}${result.message ? ` (${result.message})` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (dockerResults.length > 0) {
|
||||
console.log("docker:");
|
||||
for (const result of dockerResults) {
|
||||
console.log(` ${result.target} ${result.status}${result.message ? ` (${result.message})` : ""}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function showHelp(mode?: ICliMode): void {
|
||||
if (mode?.json) {
|
||||
printJson({
|
||||
command: "release",
|
||||
usage: "gitzone release [options]",
|
||||
description: "Creates a versioned release from pending changelog entries and publishes configured artifacts.",
|
||||
flags: [
|
||||
{ flag: "-y, --yes", description: "Run without interactive confirmation" },
|
||||
{ flag: "-t, --test", description: "Enable release preflight tests" },
|
||||
{ flag: "-b, --build", description: "Enable release preflight build" },
|
||||
{ flag: "-p, --push", description: "Enable the git release target" },
|
||||
{ flag: "--target <names>", description: "Release only selected targets: git,npm,docker" },
|
||||
{ flag: "--npm", description: "Enable the npm release target" },
|
||||
{ flag: "--docker", description: "Enable the Docker release target" },
|
||||
{ flag: "--no-publish", description: "Run release core and git target only" },
|
||||
{ flag: "--plan", description: "Show resolved workflow without mutating files" },
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log("Usage: gitzone release [options]");
|
||||
console.log("");
|
||||
console.log("Creates a versioned release from changelog Pending entries.");
|
||||
console.log("");
|
||||
console.log("Flags:");
|
||||
console.log(" -y, --yes Run without interactive confirmation");
|
||||
console.log(" -t, --test Enable release preflight tests");
|
||||
console.log(" -b, --build Enable release preflight build");
|
||||
console.log(" -p, --push Enable the git release target");
|
||||
console.log(" --target <names> Release only selected targets: git,npm,docker");
|
||||
console.log(" --npm Enable the npm release target");
|
||||
console.log(" --docker Enable the Docker release target");
|
||||
console.log(" --no-publish Run release core and git target only");
|
||||
console.log(" --major|--minor|--patch Override inferred semver level");
|
||||
console.log(" --plan Show resolved workflow without mutating files");
|
||||
console.log("");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "../plugins.js";
|
||||
|
||||
import * as commitinfo from "@push.rocks/commitinfo";
|
||||
|
||||
export { commitinfo };
|
||||
@@ -17,8 +17,9 @@ const commandSummaries: ICommandHelpSummary[] = [
|
||||
{
|
||||
name: "commit",
|
||||
description:
|
||||
"Create semantic commits or generate read-only commit recommendations",
|
||||
"Analyze changes and create semantic source commits",
|
||||
},
|
||||
{ name: "release", description: "Create versioned releases from pending changelog entries" },
|
||||
{ name: "format", description: "Plan or apply project formatting changes" },
|
||||
{ name: "config", description: "Read and change .smartconfig.json settings" },
|
||||
{ name: "services", description: "Manage or configure development services" },
|
||||
@@ -68,7 +69,8 @@ export let run = async (argvArg: any = {}) => {
|
||||
message: "What would you like to do?",
|
||||
default: "commit",
|
||||
choices: [
|
||||
{ name: "Commit changes (semantic versioning)", value: "commit" },
|
||||
{ name: "Commit changes", value: "commit" },
|
||||
{ name: "Release pending changes", value: "release" },
|
||||
{ name: "Format project files", value: "format" },
|
||||
{ name: "Configure release settings", value: "config" },
|
||||
{ name: "Create from template", value: "template" },
|
||||
@@ -86,6 +88,11 @@ export let run = async (argvArg: any = {}) => {
|
||||
await modCommit.run({ _: ["commit"] });
|
||||
break;
|
||||
}
|
||||
case "release": {
|
||||
const modRelease = await import("../mod_release/index.js");
|
||||
await modRelease.run({ _: ["release"] });
|
||||
break;
|
||||
}
|
||||
case "format": {
|
||||
const modFormat = await import("../mod_format/index.js");
|
||||
await modFormat.run({ interactive: true });
|
||||
@@ -186,6 +193,7 @@ export async function showHelp(
|
||||
console.log(" gitzone help commit");
|
||||
console.log(" gitzone config show --json");
|
||||
console.log(" gitzone commit recommend --json");
|
||||
console.log(" gitzone release --plan");
|
||||
console.log(" gitzone format plan --json");
|
||||
console.log(" gitzone services set mongodb,minio");
|
||||
console.log("");
|
||||
@@ -203,6 +211,11 @@ async function showCommandHelp(
|
||||
modCommit.showHelp(mode);
|
||||
return true;
|
||||
}
|
||||
case "release": {
|
||||
const modRelease = await import("../mod_release/index.js");
|
||||
modRelease.showHelp(mode);
|
||||
return true;
|
||||
}
|
||||
case "config": {
|
||||
const modConfig = await import("../mod_config/index.js");
|
||||
modConfig.showHelp(mode);
|
||||
|
||||
Reference in New Issue
Block a user