Compare commits

..

18 Commits

Author SHA1 Message Date
jkunz 358d677e72 v2.16.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-10 11:05:17 +00:00
jkunz f421c5851d feat(cli): add toolchain management command 2026-05-10 11:04:57 +00:00
jkunz a420157287 v2.15.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-10 10:01:18 +00:00
jkunz 0e27d54ad2 feat(cli): split commit and release into target-based workflows 2026-05-10 10:01:09 +00:00
jkunz 738fbaa64f v2.14.3
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-30 13:03:04 +00:00
jkunz fe7a9d93d1 fix(test): move test workspace into .nogit and add bundled fixture project files 2026-04-30 13:03:04 +00:00
jkunz 9a4c8795d4 v2.14.2
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-30 12:59:00 +00:00
jkunz faee6a1698 fix(package): correct package entry point extension and align test scripts with pnpm 2026-04-30 12:59:00 +00:00
jkunz 9a1044783d v2.14.1
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-16 19:44:17 +00:00
jkunz b16eb75d81 fix(repo): no changes to commit 2026-04-16 19:44:17 +00:00
jkunz 261f7ee6b2 v2.14.0
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-16 18:54:07 +00:00
jkunz fd7a73398c feat(cli): add machine-readable CLI help, recommendation, and configuration flows 2026-04-16 18:54:07 +00:00
jkunz f43f88a3cb v2.13.16
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-16 13:05:47 +00:00
jkunz 4c86ad62fb fix(mod_format): stop package.json formatter from modifying buildDocs and dependency entries 2026-04-16 13:05:47 +00:00
jkunz 4214a1fdf1 v2.13.15
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-24 19:59:26 +00:00
jkunz 1c33735799 fix(repo): no changes to commit 2026-03-24 19:59:26 +00:00
jkunz 274405e364 v2.13.14
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-24 19:59:13 +00:00
jkunz bf858c8650 fix(mod_format): move smartconfig file renaming into the formatter orchestrator 2026-03-24 19:59:13 +00:00
34 changed files with 4719 additions and 1636 deletions
+31 -6
View File
@@ -1,13 +1,12 @@
{ {
"szci": { "@ship.zone/szci": {
"npmGlobalTools": [], "npmGlobalTools": []
"npmAccessLevel": "private",
"npmRegistryUrl": "verdaccio.lossless.one"
}, },
"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" "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": { "@git.zone/cli": {
"schemaVersion": 2,
"projectType": "npm", "projectType": "npm",
"module": { "module": {
"githost": "gitlab.com", "githost": "gitlab.com",
@@ -35,12 +34,38 @@
"CI/CD" "CI/CD"
] ]
}, },
"commit": {
"confirmation": "prompt",
"steps": ["analyze", "changelog", "commit"]
},
"release": { "release": {
"confirmation": "prompt",
"preflight": {
"requireCleanTree": true,
"test": false,
"build": true
},
"targets": {
"git": {
"enabled": true,
"remote": "origin",
"pushBranch": true,
"pushTags": true
},
"npm": {
"enabled": true,
"registries": [ "registries": [
"https://verdaccio.lossless.digital", "https://verdaccio.lossless.digital",
"https://registry.npmjs.org" "https://registry.npmjs.org"
], ],
"accessLevel": "public" "accessLevel": "public",
"alreadyPublished": "success"
},
"docker": {
"enabled": false,
"images": []
}
}
} }
} }
} }
+8 -2
View File
@@ -9,6 +9,13 @@
"npmPackagename": "{{module.npmPackagename}}", "npmPackagename": "{{module.npmPackagename}}",
"license": "{{module.license}}", "license": "{{module.license}}",
"projectDomain": "{{module.projectDomain}}" "projectDomain": "{{module.projectDomain}}"
},
"release": {
"targets": {
"npm": {
"registries": ["{{npmPrivateRegistry}}"]
}
}
} }
}, },
"@ship.zone/szci": { "@ship.zone/szci": {
@@ -18,7 +25,6 @@
}, },
"dockerBuildargEnvMap": { "dockerBuildargEnvMap": {
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2" "NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
}, }
"npmRegistryUrl": "{{npmPrivateRegistry}}"
} }
} }
@@ -14,8 +14,12 @@ fileName: .smartconfig.json
"projectDomain": "{{module.projectDomain}}" "projectDomain": "{{module.projectDomain}}"
}, },
"release": { "release": {
"targets": {
"npm": {
"accessLevel": "{{module.npmAccessLevel}}" "accessLevel": "{{module.npmAccessLevel}}"
} }
}
}
}, },
"@ship.zone/szci": { "@ship.zone/szci": {
"npmGlobalTools": [] "npmGlobalTools": []
+8 -2
View File
@@ -9,6 +9,13 @@
"npmPackagename": "{{module.npmPackagename}}", "npmPackagename": "{{module.npmPackagename}}",
"license": "{{module.license}}", "license": "{{module.license}}",
"projectDomain": "{{module.projectDomain}}" "projectDomain": "{{module.projectDomain}}"
},
"release": {
"targets": {
"npm": {
"registries": ["{{private.npmRegistryUrl}}"]
}
}
} }
}, },
"@ship.zone/szci": { "@ship.zone/szci": {
@@ -18,7 +25,6 @@
}, },
"dockerBuildargEnvMap": { "dockerBuildargEnvMap": {
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2" "NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
}, }
"npmRegistryUrl": "{{private.npmRegistryUrl}}"
} }
} }
+59
View File
@@ -1,5 +1,64 @@
# Changelog # Changelog
## Pending
## 2026-05-10 - 2.16.0
### Features
- Add `gitzone tools` for managing the global `@git.zone` toolchain from the main CLI.
## 2026-05-10 - 2.15.0
### 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
- updates package scripts to clone and run the sandbox test repository from .nogit/test
- adds a complete test fixture project under test/ for CLI, template, and documentation-related test scenarios
## 2026-04-30 - 2.14.2 - fix(package)
correct package entry point extension and align test scripts with pnpm
- Change the package main field from dist_ts/index.ts to dist_ts/index.js for the built CLI entry point.
- Update test and testBuild scripts to use pnpm run instead of npm run for consistent package manager usage.
## 2026-04-16 - 2.14.1 - fix(repo)
no changes to commit
## 2026-04-16 - 2.14.0 - feat(cli)
add machine-readable CLI help, recommendation, and configuration flows
- introduces shared CLI mode handling for human, plain, and JSON output with configurable interactivity and update checks
- adds read-only JSON support for `commit recommend`, `format plan`, and command help output
- expands `config` and `services` commands with non-interactive config inspection and service enablement flows
- updates format and smartconfig handling to respect non-interactive execution and fail clearly when required metadata is missing
## 2026-04-16 - 2.13.16 - fix(mod_format)
stop package.json formatter from modifying buildDocs and dependency entries
- removes automatic buildDocs script injection from the package.json formatter
- removes dependency include/exclude and latest-version update logic from package.json formatting
- drops the unused smartnpm plugin import after removing registry lookups
## 2026-03-24 - 2.13.15 - fix(repo)
no changes to commit
## 2026-03-24 - 2.13.14 - fix(mod_format)
move smartconfig file renaming into the formatter orchestrator
- Renames smartconfig.json or npmextra.json to .smartconfig.json before formatters run
- Simplifies the smartconfig formatter to only read and modify .smartconfig.json
- Removes create/delete change planning for config renames and applies only content updates within the formatter
## 2026-03-24 - 2.13.13 - fix(vscode-template) ## 2026-03-24 - 2.13.13 - fix(vscode-template)
update VS Code schema matching to use .smartconfig.json update VS Code schema matching to use .smartconfig.json
+1 -1
View File
@@ -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 Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+16 -16
View File
@@ -1,9 +1,9 @@
{ {
"name": "@git.zone/cli", "name": "@git.zone/cli",
"private": false, "private": false,
"version": "2.13.13", "version": "2.16.0",
"description": "A comprehensive CLI tool for enhancing and managing local development workflows with gitzone utilities, focusing on project setup, version control, code formatting, and template management.", "description": "A comprehensive CLI tool for enhancing and managing local development workflows with gitzone utilities, focusing on project setup, version control, code formatting, and template management.",
"main": "dist_ts/index.ts", "main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts", "typings": "dist_ts/index.d.ts",
"type": "module", "type": "module",
"bin": { "bin": {
@@ -11,21 +11,21 @@
"gzone": "./cli.js" "gzone": "./cli.js"
}, },
"scripts": { "scripts": {
"test": "(npm run clean && npm run prepareTest && npm run testCli && npm run testFormat && npm run testCommit && npm run testDeprecate && npm run testVersion && npm run testReadme && npm run testUpdate && npm run testTemplateNpm && npm run testTemplateLit) && rm -rf test", "test": "(pnpm run clean && pnpm run prepareTest && pnpm run testCli && pnpm run testFormat && pnpm run testCommit && pnpm run testDeprecate && pnpm run testVersion && pnpm run testReadme && pnpm run testUpdate && pnpm run testTemplateNpm && pnpm run testTemplateLit) && rm -rf .nogit/test",
"build": "tsbuild tsfolders", "build": "tsbuild tsfolders",
"clean": "(rm -rf test/)", "clean": "(rm -rf .nogit/test/)",
"prepareTest": "(git clone https://gitlab.com/sandboxzone/sandbox-npmts.git test/)", "prepareTest": "(mkdir -p .nogit && git clone https://gitlab.com/sandboxzone/sandbox-npmts.git .nogit/test/)",
"testBuild": "npm run build && rm -r dist/", "testBuild": "pnpm run build && rm -r dist/",
"testCli": "(cd test && node ../cli.ts.js)", "testCli": "(cd .nogit/test && node ../../cli.ts.js)",
"testCommit": "(cd test && node ../cli.ts.js commit)", "testCommit": "(cd .nogit/test && node ../../cli.ts.js commit)",
"testDeprecate": "(cd test && node ../cli.ts.js deprecate)", "testDeprecate": "(cd .nogit/test && node ../../cli.ts.js deprecate)",
"testOpen": "(cd test && node ../cli.ts.js open ci)", "testOpen": "(cd .nogit/test && node ../../cli.ts.js open ci)",
"testReadme": "(cd test && node ../cli.ts.js readme)", "testReadme": "(cd .nogit/test && node ../../cli.ts.js readme)",
"testFormat": "(cd test && node ../cli.ts.js format)", "testFormat": "(cd .nogit/test && node ../../cli.ts.js format)",
"testTemplateNpm": "(rm -rf test/testtemplate_npm/ && mkdir test/testtemplate_npm && cd test/testtemplate_npm && node ../../cli.ts.js template npm)", "testTemplateNpm": "(rm -rf .nogit/test/testtemplate_npm/ && mkdir -p .nogit/test/testtemplate_npm && cd .nogit/test/testtemplate_npm && node ../../../cli.ts.js template npm)",
"testTemplateLit": "(rm -rf test/testtemplate_lit/ && mkdir test/testtemplate_lit && cd test/testtemplate_lit && node ../../cli.ts.js template lit)", "testTemplateLit": "(rm -rf .nogit/test/testtemplate_lit/ && mkdir -p .nogit/test/testtemplate_lit && cd .nogit/test/testtemplate_lit && node ../../../cli.ts.js template lit)",
"testUpdate": "(cd test && node ../cli.ts.js update)", "testUpdate": "(cd .nogit/test && node ../../cli.ts.js update)",
"testVersion": "(cd test && node ../cli.ts.js -v)", "testVersion": "(cd .nogit/test && node ../../cli.ts.js -v)",
"buildDocs": "tsdoc" "buildDocs": "tsdoc"
}, },
"repository": { "repository": {
+39 -48
View File
@@ -23,10 +23,10 @@ Gitzone CLI (`@git.zone/cli`) is a comprehensive toolbelt for streamlining local
### Configuration Management ### Configuration Management
- Uses `npmextra.json` for all tool configuration - Uses `.smartconfig.json` for tool configuration
- Configuration stored under `gitzone` key in npmextra - CLI settings live under the `@git.zone/cli` namespace
- No separate `.gitzonerc` file - everything in npmextra.json - Agent and non-interactive defaults now belong under `@git.zone/cli.cli`
- Project type and module metadata also stored in npmextra - Project type, module metadata, release settings, commit defaults, and format settings live in the same file
### Format Module (`mod_format`) - SIGNIFICANTLY ENHANCED ### Format Module (`mod_format`) - SIGNIFICANTLY ENHANCED
@@ -38,7 +38,7 @@ The format module is responsible for project standardization:
2. **copy** - File copying with glob patterns (fully implemented) 2. **copy** - File copying with glob patterns (fully implemented)
3. **gitignore** - Creates/updates .gitignore from templates 3. **gitignore** - Creates/updates .gitignore from templates
4. **license** - Checks dependency licenses for compatibility 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 6. **packagejson** - Formats and updates package.json
7. **prettier** - Applies code formatting with batching 7. **prettier** - Applies code formatting with batching
8. **readme** - Ensures readme files exist 8. **readme** - Ensures readme files exist
@@ -84,33 +84,31 @@ The format module is responsible for project standardization:
1. **Plan → Action Workflow**: Shows changes before applying them 1. **Plan → Action Workflow**: Shows changes before applying them
2. **Rollback Mechanism**: Full backup and restore on failures 2. **Rollback Mechanism**: Full backup and restore on failures
3. **Enhanced Configuration**: Granular control via npmextra.json 3. **Enhanced Configuration**: Granular control via `.smartconfig.json`
4. **Better Error Handling**: Detailed errors with recovery options 4. **Better Error Handling**: Detailed errors with recovery options
5. **Performance Optimizations**: Parallel execution and caching 5. **Performance Optimizations**: Parallel execution and caching
6. **Reporting**: Diff views, statistics, verbose logging 6. **Reporting**: Diff views, statistics, verbose logging
7. **Architecture**: Clean separation of concerns with new classes 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:** **Changes:**
- Removed `bumpNpmVersion()` - was causing npm warnings to pollute deno.json - `gitzone commit` analyzes changes, updates `changelog.md` `Pending`, commits, and optionally pushes.
- Removed `syncVersionToDenoJson()` - no longer needed with unified approach - `gitzone release` reads `Pending`, bumps versions, moves changelog entries into a version section, tags, pushes, and publishes configured artifacts.
- Removed separate `bumpDenoVersion()` - replaced by unified implementation - Commit workflow steps are configured in `.smartconfig.json` under `@git.zone/cli.commit.steps`.
- Added `readCurrentVersion()` helper - reads from either package.json or deno.json - Smartconfig schema versioning lives at `@git.zone/cli.schemaVersion`; run `gitzone config migrate <version>` for targeted migrations.
- Added `updateVersionFile()` helper - updates JSON files directly - Release publishing is target-based under `@git.zone/cli.release.targets`.
- Unified `bumpProjectVersion()` - handles npm/deno/both with single clean code path - NPM registries only live under `@git.zone/cli.release.targets.npm.registries`.
**Benefits:** **Benefits:**
- No npm warning pollution in version fields - Commit is safer and has no publishing side effects.
- Full control over version bumping process - Multiple source commits can accumulate into one release via `Pending`.
- Simpler git history (no amending, no force-tagging) - Per-artifact release results can distinguish published, already-published, skipped, and failed targets.
- Same code path for all project types
- Reuses existing `calculateNewVersion()` function
### Auto-Accept Flag for Commits ### Auto-Accept Flag for Commits
@@ -132,7 +130,7 @@ The commit module now supports `-y/--yes` flag for non-interactive commits:
## Development Tips ## Development Tips
- Always check readme.plan.md for ongoing improvement plans - Always check readme.plan.md for ongoing improvement plans
- Use npmextra.json for any new configuration options - Use `.smartconfig.json` for any new configuration options
- Keep modules focused and single-purpose - Keep modules focused and single-purpose
- Maintain the existing plugin pattern for dependencies - Maintain the existing plugin pattern for dependencies
- Test format operations on sample projects before deploying - Test format operations on sample projects before deploying
@@ -144,30 +142,18 @@ The commit module now supports `-y/--yes` flag for non-interactive commits:
```json ```json
{ {
"gitzone": { "@git.zone/cli": {
"cli": {
"interactive": true,
"output": "human",
"checkUpdates": true
},
"format": { "format": {
"interactive": true, "interactive": true,
"parallel": true,
"showStats": true, "showStats": true,
"cache": {
"enabled": true,
"clean": true
},
"rollback": {
"enabled": true,
"autoRollbackOnError": true,
"backupRetentionDays": 7
},
"modules": { "modules": {
"skip": ["prettier"], "skip": ["prettier"],
"only": [], "only": []
"order": []
},
"licenses": {
"allowed": ["MIT", "Apache-2.0"],
"exceptions": {
"some-package": "GPL-3.0"
}
} }
} }
} }
@@ -182,6 +168,9 @@ The commit module now supports `-y/--yes` flag for non-interactive commits:
# Interactive commit (default) # Interactive commit (default)
gitzone commit gitzone commit
# Read-only recommendation
gitzone commit recommend --json
# Auto-accept AI recommendations (no prompts) # Auto-accept AI recommendations (no prompts)
gitzone commit -y gitzone commit -y
gitzone commit --yes gitzone commit --yes
@@ -201,11 +190,14 @@ gitzone commit --format
# Basic format # Basic format
gitzone format gitzone format
# Read-only JSON plan
gitzone format plan --json
# Dry run to preview changes # Dry run to preview changes
gitzone format --dry-run gitzone format --dry-run
# Non-interactive mode # Non-interactive apply
gitzone format --yes gitzone format --write --yes
# Plan only (no execution) # Plan only (no execution)
gitzone format --plan-only gitzone format --plan-only
@@ -222,11 +214,10 @@ gitzone format --verbose
# Detailed diff views # Detailed diff views
gitzone format --detailed gitzone format --detailed
# Rollback operations # Inspect config for agents and scripts
gitzone format --rollback gitzone config show --json
gitzone format --rollback <operation-id> gitzone config set cli.output json
gitzone format --list-backups gitzone config get release.targets.npm.accessLevel
gitzone format --clean-backups
``` ```
## Common Issues (Now Resolved) ## Common Issues (Now Resolved)
@@ -298,7 +289,7 @@ All sync methods must become async. Functions that were previously synchronous (
**Affected Modules:** **Affected Modules:**
- ts/mod_format/\* (largest area - 15+ files) - 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_services/\* (configuration management)
- ts/mod_meta/\* (meta repository management) - ts/mod_meta/\* (meta repository management)
- ts/mod_standard/\* (template listing) - ts/mod_standard/\* (template listing)
+283 -425
View File
@@ -1,365 +1,244 @@
# @git.zone/cli 🚀 # @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.
[![npm version](https://img.shields.io/npm/v/@git.zone/cli.svg)](https://www.npmjs.com/package/@git.zone/cli) 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.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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.
## Issue Reporting and Security ## 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. 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 ## Install
### Installation
```bash ```bash
# Install globally via pnpm (recommended)
pnpm add -g @git.zone/cli 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. After installation, both binaries point to the same CLI:
### Your First Commands
```bash ```bash
# Create a new TypeScript npm package gitzone --help
gitzone template npm 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 gitzone format
# Apply formatting changes # Apply formatting changes
gitzone format --write gitzone format --write
# Start local MongoDB and MinIO services # Create a semantic source commit
gitzone services start
# Create a semantic commit with AI-powered suggestions
gitzone 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 |
| `tools` | Manage the global `@git.zone` toolchain |
| `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`.
## Toolchain Management
`gitzone tools` replaces the former `gtools` command from `@git.zone/tools`. It manages globally installed `@git.zone` development tools through pnpm.
```bash ```bash
# Interactive commit with AI recommendations # Check installed @git.zone tools and update outdated packages
gitzone tools update
# Update without prompts
gitzone tools update -y
# Install missing managed @git.zone tools
gitzone tools install
```
`gitzone tools update` checks `@git.zone/cli` first. If the CLI itself needs an update, it updates `@git.zone/cli` and asks you to rerun the command before updating the rest of the toolchain.
## Commit Workflow
`gitzone commit` creates one semantic source commit. It does not bump versions, create tags, publish packages, or push Docker images.
```bash
# Interactive semantic commit
gitzone commit gitzone commit
# Auto-accept AI recommendations (skipped for BREAKING CHANGEs) # Read-only AI recommendation
gitzone commit recommend --json
# Auto-accept safe recommendations
gitzone commit -y gitzone commit -y
# Auto-accept, push, build, and release # Auto-accept, test, build, and push
gitzone commit -ypbr gitzone commit -ytbp
# Show the resolved workflow without mutating anything
gitzone commit --plan
``` ```
**Flags:** The commit flow:
| Flag | Long Form | Description | 1. Analyze the working tree.
|------|-----------|-------------| 2. Suggest commit type, scope, and message.
| `-y` | `--yes` | Auto-accept AI recommendations | 3. Write a human-readable entry into `changelog.md` under `## Pending`.
| `-p` | `--push` | Push to remote after commit | 4. Stage and create one semantic source commit.
| `-t` | `--test` | Run tests before committing | 5. Optionally run formatting, tests, build, and push based on flags or config.
| `-b` | `--build` | Build after commit, verify clean tree |
| `-r` | `--release` | Publish to configured npm registries |
| | `--format` | Run format before committing |
**Workflow steps:** Commit flags:
1. 🤖 **AI-powered analysis** — analyzes your changes and suggests commit type, scope, and message | Flag | Meaning |
2. 📝 Interactive commit message builder (type: `fix`/`feat`/`BREAKING CHANGE`, scope, description) | --- | --- |
3. 📜 Automatic changelog generation | `-y`, `--yes` | Auto-accept safe recommendations |
4. 🏷️ Automatic version bumping (major/minor/patch) with git tag creation | `-t`, `--test` | Add test step |
5. 🔨 Optional build & verification | `-b`, `--build` | Add build step |
6. 🚀 Optional push to origin | `-p`, `--push` | Push after the source commit |
7. 📦 Optional publish to npm registries | `-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 ```bash
# Preview what would change (default behavior) # Preview the resolved release plan
gitzone format gitzone release --plan
# Apply changes # Release to configured targets
gitzone format --write gitzone release
# Auto-approve without prompts # Release only to npm
gitzone format --yes --write gitzone release --target npm
# Show detailed diffs # Release only to git and Docker
gitzone format --diff gitzone release --target git,docker
# Enable verbose logging # Skip package/container publishing and keep only git target
gitzone format --verbose gitzone release --no-publish
# Override inferred semver level
gitzone release --minor
``` ```
**Flags:** Release flags:
| Flag | Description | | Flag | Meaning |
|------|-------------| | --- | --- |
| `--write` / `-w` | Apply changes (default is dry-run) | | `-y`, `--yes` | Run without interactive confirmation |
| `--yes` | Auto-approve without interactive confirmation | | `-t`, `--test` | Enable preflight tests |
| `--plan-only` | Only show what would be done | | `-b`, `--build` | Enable preflight build |
| `--save-plan <file>` | Save the format plan to a file | | `-p`, `--push` | Enable the `git` target |
| `--from-plan <file>` | Load and execute a saved plan | | `--target <csv>` | Use only selected targets, e.g. `git,npm` |
| `--detailed` | Show detailed stats and save report | | `--npm` | Enable the `npm` target |
| `--parallel` / `--no-parallel` | Toggle parallel execution | | `--docker` | Enable the `docker` target |
| `--verbose` | Enable verbose logging | | `--no-publish` | Keep release core and `git` target only |
| `--diff` | Show file diffs | | `--no-build` | Disable preflight build for this run |
| `--major`, `--minor`, `--patch` | Override inferred semver level |
| `--plan` | Show resolved workflow only |
**Formatters (executed in order):** ## Standard Changelog
1. 🧹 **Cleanup** — removes obsolete files (yarn.lock, package-lock.json, tslint.json, etc.) The changelog is convention-based and intentionally not configured.
2. ⚙️ **Npmextra** — formats and standardizes `npmextra.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
### 🐳 Development Services Management `gitzone commit` appends entries to:
Effortlessly manage local development services (MongoDB, MinIO S3, Elasticsearch) with Docker: ```markdown
## Pending
```bash
gitzone services [command]
``` ```
**Commands:** `gitzone release` moves those pending entries into a dated version section:
| Command | Description | ```markdown
|---------|-------------| ## 2026-05-10 - 2.15.0
| `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 |
| `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 (2000030000) 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:** The standard buckets are `Breaking Changes`, `Features`, `Fixes`, `Documentation`, and `Maintenance`.
```bash ## Configuration
# Start all services for your project
gitzone services start
# Check what's running All CLI config lives under `@git.zone/cli` in `.smartconfig.json`.
gitzone services status
# Get MongoDB Compass connection string
gitzone services compass
# Output: mongodb://defaultadmin:defaultpass@192.168.1.100:27018/myproject?authSource=admin
# View MongoDB logs
gitzone services logs mongo 50
# Stop services when done
gitzone services stop
```
### ⚙️ Release & Commit Configuration
Manage release registries and commit settings:
```bash
gitzone config [subcommand]
```
| Command | Description |
|---------|-------------|
| `show` | Display current release config (registries, access level) |
| `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 |
Configuration is stored in `npmextra.json` under the `@git.zone/cli` key.
### 📦 Project Templates
Instantly scaffold production-ready projects with best practices built-in:
```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 update
# Remove a sub-project
gitzone meta remove [name]
```
### 🐳 Docker Management
Streamline your Docker workflow:
```bash
# Clean up all Docker resources (containers, images, volumes, networks)
gitzone docker prune
```
### 🔗 Quick CI/CD Access
Jump directly to your CI/CD configurations:
```bash
# Open CI/CD settings
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
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
gitzone start
```
Automatically checks out master, pulls latest changes, and installs dependencies.
### 🔧 Helper Utilities
```bash
# Generate a unique short ID
gitzone helpers shortid
```
## 📋 Configuration
### npmextra.json
Customize gitzone behavior through `npmextra.json`:
```json ```json
{ {
"@git.zone/cli": { "@git.zone/cli": {
"schemaVersion": 2,
"projectType": "npm", "projectType": "npm",
"release": {
"registries": [
"https://registry.npmjs.org"
],
"accessLevel": "public"
},
"commit": { "commit": {
"alwaysTest": false, "confirmation": "prompt",
"alwaysBuild": false "steps": ["analyze", "test", "build", "changelog", "commit", "push"]
}
}, },
"gitzone": { "release": {
"format": { "confirmation": "prompt",
"interactive": true, "preflight": {
"parallel": true, "requireCleanTree": true,
"showStats": true, "test": false,
"cache": { "build": true
},
"targets": {
"git": {
"enabled": true, "enabled": true,
"clean": true "remote": "origin",
"pushBranch": true,
"pushTags": true
}, },
"modules": { "npm": {
"skip": ["prettier"], "enabled": true,
"only": [], "registries": ["https://registry.npmjs.org"],
"order": [] "accessLevel": "public",
"alreadyPublished": "success"
}, },
"licenses": { "docker": {
"allowed": ["MIT", "Apache-2.0"], "enabled": false,
"exceptions": { "images": []
"some-package": "GPL-3.0"
} }
} }
} }
@@ -367,186 +246,165 @@ Customize gitzone behavior through `npmextra.json`:
} }
``` ```
### Environment Variables NPM registries belong only here:
- `CI` — Detect CI environment for automated workflows ```text
- `DEBUG` — Enable debug output @git.zone/cli.release.targets.npm.registries
- `GITZONE_FORMAT_PARALLEL` — Control parallel formatting ```
## 🎯 Common Workflows Useful config commands:
### Full-Stack Development Cycle
```bash ```bash
# 1. Start fresh # Show current @git.zone/cli config
gitzone start gitzone config show --json
# 2. Spin up databases and services # Read the npm release target registries
gitzone services start gitzone config get release.targets.npm.registries
# 3. Make changes # Add an npm release target registry
# ... your development work ... gitzone config add https://registry.npmjs.org
# 4. Check service logs if needed # Set npm target access level
gitzone services logs mongo gitzone config access public
# 5. Preview format changes, then apply # 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 gitzone format
# Emit a machine-readable plan
gitzone format plan --json
# Apply changes
gitzone format --write gitzone format --write
# 6. Commit with semantic versioning # Apply without prompt
gitzone commit gitzone format --write --yes
# 7. Stop services when done
gitzone services stop
``` ```
### Automated CI/CD Commit Formatters include cleanup, smartconfig normalization, dependency license checks, package metadata normalization, template updates, `.gitignore`, TypeScript config, Prettier, README existence checks, and configured copy operations.
## Development Services
`gitzone services` manages local Docker-backed services for development projects.
Supported services:
| Service | Aliases |
| --- | --- |
| MongoDB | `mongo`, `mongodb` |
| MinIO | `minio`, `s3` |
| Elasticsearch | `elasticsearch`, `es` |
```bash ```bash
# Auto-accept, test, build, push, and release in one command # Start configured services
gitzone commit -ytbpr
```
### 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 gitzone services start
# 2. Get connection details # Enable specific services non-interactively
gitzone services config gitzone services set mongodb,minio
# 3. Connect with MongoDB Compass # Check status
gitzone services compass
# 4. Monitor services
gitzone services status gitzone services status
# 5. Clean everything when done # Print MongoDB Compass connection string
gitzone services clean # ⚠️ Warning: deletes data gitzone services compass
# Show logs
gitzone services logs mongo 50
# Stop containers but keep data
gitzone services stop
# Remove containers and data
gitzone services clean
``` ```
## 🔌 Integrations Service config is stored in `.nogit/env.json`. Data is stored below `.nogit/`, so it stays out of Git.
### CI/CD Platforms ## Templates
- **GitLab CI** — full pipeline support with templates Start new projects with built-in scaffolds:
- **GitHub Actions** — automated workflows
- **Docker** — container-based deployments
### Development Tools ```bash
gitzone template npm
gitzone template service
gitzone template website
gitzone template wcc
```
- **TypeScript** — first-class support Templates are rendered through SmartScaf and then can be normalized with `gitzone format`.
- **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 ## Meta Repositories
- **Git** — deep integration Use `gitzone meta` when one workspace coordinates multiple repositories.
- **Semantic Versioning** — automatic version bumping
- **Conventional Commits** — standardized commit messages
- **AI-Powered Analysis** — intelligent commit suggestions via `@git.zone/tsdoc`
## 💡 Pro Tips ```bash
gitzone meta init
gitzone meta add frontend https://example.com/org/frontend.git
gitzone meta update
gitzone meta remove frontend
```
1. **Use aliases**: Add `alias gz='gitzone'` to your shell profile ## Other Utilities
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 ```bash
# Docker cleanup
gitzone docker prune
### Format Command Shows "Cancelled" # Open GitLab CI settings or pipelines for the current repo
gitzone open ci
gitzone open pipelines
- Check your `npmextra.json` configuration # Deprecate an old npm package interactively
- Try with `--yes --write` flags gitzone deprecate
- Use `--verbose` for detailed output
### Docker Commands Fail # Prepare a project for local work
gitzone start
Ensure Docker daemon is running: # Generate a short unique ID
gitzone helpers shortid
```
## Troubleshooting
Format only previews changes:
```bash
gitzone format --write
```
Release says there is nothing to release:
```bash
# Make sure commits have populated the Pending changelog section
gitzone commit
```
Docker services fail to start:
```bash ```bash
docker info docker info
``` gitzone services status
### 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 reconfigure gitzone services reconfigure
``` ```
### Template Creation Issues Config looks outdated:
Verify pnpm/npm is properly configured:
```bash ```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 ## 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. **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@git.zone/cli', name: '@git.zone/cli',
version: '2.13.13', version: '2.16.0',
description: 'A comprehensive CLI tool for enhancing and managing local development workflows with gitzone utilities, focusing on project setup, version control, code formatting, and template management.' description: 'A comprehensive CLI tool for enhancing and managing local development workflows with gitzone utilities, focusing on project setup, version control, code formatting, and template management.'
} }
+62 -34
View File
@@ -1,23 +1,29 @@
import * as plugins from './plugins.js'; import * as plugins from "./plugins.js";
import * as paths from './paths.js'; import * as paths from "./paths.js";
import { GitzoneConfig } from './classes.gitzoneconfig.js'; import { GitzoneConfig } from "./classes.gitzoneconfig.js";
import { getRawCliMode } from "./helpers.climode.js";
const gitzoneSmartcli = new plugins.smartcli.Smartcli(); const gitzoneSmartcli = new plugins.smartcli.Smartcli();
export let run = async () => { export let run = async () => {
const done = plugins.smartpromise.defer(); const done = plugins.smartpromise.defer();
const rawCliMode = await getRawCliMode();
// get packageInfo // get packageInfo
const projectInfo = new plugins.projectinfo.ProjectInfo(paths.packageDir); const projectInfo = new plugins.projectinfo.ProjectInfo(paths.packageDir);
// check for updates // check for updates
if (rawCliMode.checkUpdates) {
const smartupdateInstance = new plugins.smartupdate.SmartUpdate(); const smartupdateInstance = new plugins.smartupdate.SmartUpdate();
await smartupdateInstance.check( await smartupdateInstance.check(
'gitzone', "gitzone",
projectInfo.npm.version, projectInfo.npm.version,
'http://gitzone.gitlab.io/gitzone/changelog.html', "http://gitzone.gitlab.io/gitzone/changelog.html",
); );
console.log('---------------------------------------------'); }
if (rawCliMode.output === "human") {
console.log("---------------------------------------------");
}
gitzoneSmartcli.addVersion(projectInfo.npm.version); gitzoneSmartcli.addVersion(projectInfo.npm.version);
// ======> Standard task <====== // ======> Standard task <======
@@ -26,8 +32,13 @@ export let run = async () => {
* standard task * standard task
*/ */
gitzoneSmartcli.standardCommand().subscribe(async (argvArg) => { gitzoneSmartcli.standardCommand().subscribe(async (argvArg) => {
const modStandard = await import('./mod_standard/index.js'); const modStandard = await import("./mod_standard/index.js");
await modStandard.run(); await modStandard.run(argvArg);
});
gitzoneSmartcli.addCommand("help").subscribe(async (argvArg) => {
const modStandard = await import("./mod_standard/index.js");
await modStandard.run(argvArg);
}); });
// ======> Specific tasks <====== // ======> Specific tasks <======
@@ -35,43 +46,52 @@ export let run = async () => {
/** /**
* commit something * commit something
*/ */
gitzoneSmartcli.addCommand('commit').subscribe(async (argvArg) => { gitzoneSmartcli.addCommand("commit").subscribe(async (argvArg) => {
const modCommit = await import('./mod_commit/index.js'); const modCommit = await import("./mod_commit/index.js");
await modCommit.run(argvArg); 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 * deprecate a package on npm
*/ */
gitzoneSmartcli.addCommand('deprecate').subscribe(async (argvArg) => { gitzoneSmartcli.addCommand("deprecate").subscribe(async (argvArg) => {
const modDeprecate = await import('./mod_deprecate/index.js'); const modDeprecate = await import("./mod_deprecate/index.js");
await modDeprecate.run(); await modDeprecate.run();
}); });
/** /**
* docker * docker
*/ */
gitzoneSmartcli.addCommand('docker').subscribe(async (argvArg) => { gitzoneSmartcli.addCommand("docker").subscribe(async (argvArg) => {
const modDocker = await import('./mod_docker/index.js'); const modDocker = await import("./mod_docker/index.js");
await modDocker.run(argvArg); await modDocker.run(argvArg);
}); });
/** /**
* Update all files that comply with the gitzone standard * Update all files that comply with the gitzone standard
*/ */
gitzoneSmartcli.addCommand('format').subscribe(async (argvArg) => { gitzoneSmartcli.addCommand("format").subscribe(async (argvArg) => {
const config = GitzoneConfig.fromCwd(); const config = GitzoneConfig.fromCwd();
const modFormat = await import('./mod_format/index.js'); const modFormat = await import("./mod_format/index.js");
// Handle format with options // Handle format with options
// Default is dry-mode, use --write/-w to apply changes // Default is dry-mode, use --write/-w to apply changes
await modFormat.run({ await modFormat.run({
...argvArg,
write: argvArg.write || argvArg.w, write: argvArg.write || argvArg.w,
dryRun: argvArg['dry-run'], dryRun: argvArg["dry-run"],
yes: argvArg.yes, yes: argvArg.yes,
planOnly: argvArg['plan-only'], planOnly: argvArg["plan-only"],
savePlan: argvArg['save-plan'], savePlan: argvArg["save-plan"],
fromPlan: argvArg['from-plan'], fromPlan: argvArg["from-plan"],
detailed: argvArg.detailed, detailed: argvArg.detailed,
interactive: argvArg.interactive !== false, interactive: argvArg.interactive !== false,
verbose: argvArg.verbose, verbose: argvArg.verbose,
@@ -82,54 +102,62 @@ export let run = async () => {
/** /**
* run meta commands * run meta commands
*/ */
gitzoneSmartcli.addCommand('meta').subscribe(async (argvArg) => { gitzoneSmartcli.addCommand("meta").subscribe(async (argvArg) => {
const config = GitzoneConfig.fromCwd(); const config = GitzoneConfig.fromCwd();
const modMeta = await import('./mod_meta/index.js'); const modMeta = await import("./mod_meta/index.js");
modMeta.run(argvArg); modMeta.run(argvArg);
}); });
/** /**
* open assets * open assets
*/ */
gitzoneSmartcli.addCommand('open').subscribe(async (argvArg) => { gitzoneSmartcli.addCommand("open").subscribe(async (argvArg) => {
const modOpen = await import('./mod_open/index.js'); const modOpen = await import("./mod_open/index.js");
modOpen.run(argvArg); modOpen.run(argvArg);
}); });
/** /**
* add a readme to a project * add a readme to a project
*/ */
gitzoneSmartcli.addCommand('template').subscribe(async (argvArg) => { gitzoneSmartcli.addCommand("template").subscribe(async (argvArg) => {
const modTemplate = await import('./mod_template/index.js'); const modTemplate = await import("./mod_template/index.js");
modTemplate.run(argvArg); modTemplate.run(argvArg);
}); });
/** /**
* start working on a project * start working on a project
*/ */
gitzoneSmartcli.addCommand('start').subscribe(async (argvArg) => { gitzoneSmartcli.addCommand("start").subscribe(async (argvArg) => {
const modTemplate = await import('./mod_start/index.js'); const modTemplate = await import("./mod_start/index.js");
modTemplate.run(argvArg); modTemplate.run(argvArg);
}); });
gitzoneSmartcli.addCommand('helpers').subscribe(async (argvArg) => { gitzoneSmartcli.addCommand("helpers").subscribe(async (argvArg) => {
const modHelpers = await import('./mod_helpers/index.js'); const modHelpers = await import("./mod_helpers/index.js");
modHelpers.run(argvArg); modHelpers.run(argvArg);
}); });
/**
* manage the global @git.zone toolchain
*/
gitzoneSmartcli.addCommand("tools").subscribe(async (argvArg) => {
const modTools = await import("./mod_tools/index.js");
await modTools.run(argvArg);
});
/** /**
* manage release configuration * manage release configuration
*/ */
gitzoneSmartcli.addCommand('config').subscribe(async (argvArg) => { gitzoneSmartcli.addCommand("config").subscribe(async (argvArg) => {
const modConfig = await import('./mod_config/index.js'); const modConfig = await import("./mod_config/index.js");
await modConfig.run(argvArg); await modConfig.run(argvArg);
}); });
/** /**
* manage development services (MongoDB, S3/MinIO) * manage development services (MongoDB, S3/MinIO)
*/ */
gitzoneSmartcli.addCommand('services').subscribe(async (argvArg) => { gitzoneSmartcli.addCommand("services").subscribe(async (argvArg) => {
const modServices = await import('./mod_services/index.js'); const modServices = await import("./mod_services/index.js");
await modServices.run(argvArg); await modServices.run(argvArg);
}); });
+165
View File
@@ -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);
};
+212
View File
@@ -0,0 +1,212 @@
import { getCliConfigValue } from "./helpers.smartconfig.js";
export type TCliOutputMode = "human" | "plain" | "json";
export interface ICliMode {
output: TCliOutputMode;
interactive: boolean;
json: boolean;
plain: boolean;
quiet: boolean;
yes: boolean;
help: boolean;
agent: boolean;
checkUpdates: boolean;
isTty: boolean;
command?: string;
}
interface ICliConfigSettings {
interactive?: boolean;
output?: TCliOutputMode;
checkUpdates?: boolean;
}
type TArgSource = Record<string, any> & { _?: string[] };
const camelCase = (value: string): string => {
return value.replace(/-([a-z])/g, (_match, group: string) =>
group.toUpperCase(),
);
};
const getArgValue = (argvArg: TArgSource, key: string): any => {
const keyVariants = [key, camelCase(key), key.replace(/-/g, "")];
for (const keyVariant of keyVariants) {
if (argvArg[keyVariant] !== undefined) {
return argvArg[keyVariant];
}
}
return undefined;
};
const parseRawArgv = (argv: string[]): TArgSource => {
const parsedArgv: TArgSource = { _: [] };
for (let i = 0; i < argv.length; i++) {
const currentArg = argv[i];
if (currentArg.startsWith("--no-")) {
const key = currentArg.slice(5);
parsedArgv[key] = false;
parsedArgv[camelCase(key)] = false;
continue;
}
if (currentArg.startsWith("--")) {
const withoutPrefix = currentArg.slice(2);
const [rawKey, inlineValue] = withoutPrefix.split("=", 2);
if (inlineValue !== undefined) {
parsedArgv[rawKey] = inlineValue;
parsedArgv[camelCase(rawKey)] = inlineValue;
continue;
}
const nextArg = argv[i + 1];
if (nextArg && !nextArg.startsWith("-")) {
parsedArgv[rawKey] = nextArg;
parsedArgv[camelCase(rawKey)] = nextArg;
i++;
} else {
parsedArgv[rawKey] = true;
parsedArgv[camelCase(rawKey)] = true;
}
continue;
}
if (currentArg.startsWith("-") && currentArg.length > 1) {
for (const shortFlag of currentArg.slice(1).split("")) {
parsedArgv[shortFlag] = true;
}
continue;
}
parsedArgv._ = parsedArgv._ || [];
parsedArgv._.push(currentArg);
}
return parsedArgv;
};
const normalizeOutputMode = (value: unknown): TCliOutputMode | undefined => {
if (value === "human" || value === "plain" || value === "json") {
return value;
}
return undefined;
};
const resolveCliMode = (
argvArg: TArgSource,
cliConfig: ICliConfigSettings,
): ICliMode => {
const isTty = Boolean(process.stdout?.isTTY && process.stdin?.isTTY);
const agentMode = Boolean(getArgValue(argvArg, "agent"));
const outputOverride = normalizeOutputMode(getArgValue(argvArg, "output"));
let output: TCliOutputMode =
normalizeOutputMode(cliConfig.output) || (isTty ? "human" : "plain");
if (agentMode || getArgValue(argvArg, "json")) {
output = "json";
} else if (getArgValue(argvArg, "plain")) {
output = "plain";
} else if (outputOverride) {
output = outputOverride;
}
const interactiveSetting = getArgValue(argvArg, "interactive");
let interactive = cliConfig.interactive ?? isTty;
if (interactiveSetting === true) {
interactive = true;
} else if (interactiveSetting === false) {
interactive = false;
}
if (!isTty || output !== "human" || agentMode) {
interactive = false;
}
const checkUpdatesSetting = getArgValue(argvArg, "check-updates");
let checkUpdates = cliConfig.checkUpdates ?? output === "human";
if (checkUpdatesSetting === true) {
checkUpdates = true;
} else if (checkUpdatesSetting === false) {
checkUpdates = false;
}
if (output !== "human" || agentMode) {
checkUpdates = false;
}
return {
output,
interactive,
json: output === "json",
plain: output === "plain",
quiet: Boolean(
getArgValue(argvArg, "quiet") ||
getArgValue(argvArg, "q") ||
output === "json",
),
yes: Boolean(getArgValue(argvArg, "yes") || getArgValue(argvArg, "y")),
help: Boolean(
getArgValue(argvArg, "help") ||
getArgValue(argvArg, "h") ||
argvArg._?.[0] === "help",
),
agent: agentMode,
checkUpdates,
isTty,
command: argvArg._?.[0],
};
};
const getCliModeConfig = async (): Promise<ICliConfigSettings> => {
return await getCliConfigValue<ICliConfigSettings>("cli", {});
};
export const getCliMode = async (
argvArg: TArgSource = {},
): Promise<ICliMode> => {
const cliConfig = await getCliModeConfig();
return resolveCliMode(argvArg, cliConfig);
};
export const getRawCliMode = async (): Promise<ICliMode> => {
const cliConfig = await getCliModeConfig();
const rawArgv = parseRawArgv(process.argv.slice(2));
return resolveCliMode(rawArgv, cliConfig);
};
export const printJson = (data: unknown): void => {
console.log(JSON.stringify(data, null, 2));
};
export const runWithSuppressedOutput = async <T>(
fn: () => Promise<T>,
): Promise<T> => {
const originalConsole = {
log: console.log,
info: console.info,
warn: console.warn,
error: console.error,
};
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
const originalStderrWrite = process.stderr.write.bind(process.stderr);
const noop = () => undefined;
console.log = noop;
console.info = noop;
console.warn = noop;
console.error = noop;
process.stdout.write = (() => true) as typeof process.stdout.write;
process.stderr.write = (() => true) as typeof process.stderr.write;
try {
return await fn();
} finally {
console.log = originalConsole.log;
console.info = originalConsole.info;
console.warn = originalConsole.warn;
console.error = originalConsole.error;
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
}
};
+192
View File
@@ -0,0 +1,192 @@
import * as plugins from "./plugins.js";
import { rename, writeFile } from "fs/promises";
export const CLI_NAMESPACE = "@git.zone/cli";
const isPlainObject = (value: unknown): value is Record<string, any> => {
return typeof value === "object" && value !== null && !Array.isArray(value);
};
export const getSmartconfigPath = (cwd: string = process.cwd()): string => {
return plugins.path.join(cwd, ".smartconfig.json");
};
export const readSmartconfigFile = async (
cwd: string = process.cwd(),
): Promise<Record<string, any>> => {
const smartconfigPath = getSmartconfigPath(cwd);
if (!(await plugins.smartfs.file(smartconfigPath).exists())) {
return {};
}
const content = (await plugins.smartfs
.file(smartconfigPath)
.encoding("utf8")
.read()) as string;
if (content.trim() === "") {
return {};
}
return JSON.parse(content);
};
export const writeSmartconfigFile = async (
data: Record<string, any>,
cwd: string = process.cwd(),
): Promise<void> => {
const smartconfigPath = getSmartconfigPath(cwd);
const tempPath = `${smartconfigPath}.tmp-${Date.now()}`;
const content = JSON.stringify(data, null, 2);
await writeFile(tempPath, content, "utf8");
await rename(tempPath, smartconfigPath);
};
export const normalizeCliConfigPath = (configPath: string): string => {
const trimmedPath = configPath.trim();
if (!trimmedPath || trimmedPath === CLI_NAMESPACE) {
return "";
}
if (trimmedPath.startsWith(`${CLI_NAMESPACE}.`)) {
return trimmedPath.slice(`${CLI_NAMESPACE}.`.length);
}
return trimmedPath;
};
export const getCliConfigPathSegments = (configPath: string): string[] => {
const normalizedPath = normalizeCliConfigPath(configPath);
if (!normalizedPath) {
return [];
}
return normalizedPath
.split(".")
.map((segment) => segment.trim())
.filter(Boolean);
};
export const getCliNamespaceConfig = (
smartconfigData: Record<string, any>,
): Record<string, any> => {
const cliConfig = smartconfigData[CLI_NAMESPACE];
if (isPlainObject(cliConfig)) {
return cliConfig;
}
return {};
};
export const getCliConfigValueFromData = (
smartconfigData: Record<string, any>,
configPath: string,
): any => {
const segments = getCliConfigPathSegments(configPath);
let currentValue: any = getCliNamespaceConfig(smartconfigData);
for (const segment of segments) {
if (!isPlainObject(currentValue) && !Array.isArray(currentValue)) {
return undefined;
}
currentValue = (currentValue as any)?.[segment];
}
return currentValue;
};
export const getCliConfigValue = async <T>(
configPath: string,
defaultValue: T,
cwd: string = process.cwd(),
): Promise<T> => {
const smartconfigData = await readSmartconfigFile(cwd);
const configValue = getCliConfigValueFromData(smartconfigData, configPath);
if (configValue === undefined) {
return defaultValue;
}
if (isPlainObject(defaultValue) && isPlainObject(configValue)) {
return {
...defaultValue,
...configValue,
} as T;
}
return configValue as T;
};
export const setCliConfigValueInData = (
smartconfigData: Record<string, any>,
configPath: string,
value: any,
): Record<string, any> => {
const segments = getCliConfigPathSegments(configPath);
if (!isPlainObject(smartconfigData[CLI_NAMESPACE])) {
smartconfigData[CLI_NAMESPACE] = {};
}
if (segments.length === 0) {
smartconfigData[CLI_NAMESPACE] = value;
return smartconfigData;
}
let currentValue = smartconfigData[CLI_NAMESPACE];
for (const segment of segments.slice(0, -1)) {
if (!isPlainObject(currentValue[segment])) {
currentValue[segment] = {};
}
currentValue = currentValue[segment];
}
currentValue[segments[segments.length - 1]] = value;
return smartconfigData;
};
export const unsetCliConfigValueInData = (
smartconfigData: Record<string, any>,
configPath: string,
): boolean => {
const segments = getCliConfigPathSegments(configPath);
if (segments.length === 0) {
if (smartconfigData[CLI_NAMESPACE] !== undefined) {
delete smartconfigData[CLI_NAMESPACE];
return true;
}
return false;
}
const parentSegments = segments.slice(0, -1);
let currentValue: any = getCliNamespaceConfig(smartconfigData);
const objectPath: Array<Record<string, any>> = [currentValue];
for (const segment of parentSegments) {
if (!isPlainObject(currentValue[segment])) {
return false;
}
currentValue = currentValue[segment];
objectPath.push(currentValue);
}
const lastSegment = segments[segments.length - 1];
if (!(lastSegment in currentValue)) {
return false;
}
delete currentValue[lastSegment];
for (let i = objectPath.length - 1; i >= 1; i--) {
if (Object.keys(objectPath[i]).length > 0) {
break;
}
const parentObject = objectPath[i - 1];
const parentKey = parentSegments[i - 1];
delete parentObject[parentKey];
}
if (Object.keys(getCliNamespaceConfig(smartconfigData)).length === 0) {
delete smartconfigData[CLI_NAMESPACE];
}
return true;
};
+192
View File
@@ -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),
};
};
+387
View File
@@ -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 || [],
};
};
+305 -280
View File
@@ -1,346 +1,371 @@
// this file contains code to create commits in a consistent way // this file contains code to create commits in a consistent way
import * as plugins from './mod.plugins.js'; import * as plugins from "./mod.plugins.js";
import * as paths from '../paths.js'; import * as paths from "../paths.js";
import { logger } from '../gitzone.logging.js'; import { logger } from "../gitzone.logging.js";
import * as helpers from './mod.helpers.js'; import * as ui from "./mod.ui.js";
import * as ui from './mod.ui.js'; import type { ICliMode } from "../helpers.climode.js";
import { ReleaseConfig } from '../mod_config/classes.releaseconfig.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) => { export const run = async (argvArg: any) => {
// Read commit config from .smartconfig.json const mode = await getCliMode(argvArg);
const smartconfigInstance = new plugins.smartconfig.Smartconfig(); const subcommand = argvArg._?.[1];
const gitzoneConfig = smartconfigInstance.dataFor<{
commit?: {
alwaysTest?: boolean;
alwaysBuild?: boolean;
};
}>('@git.zone/cli', {});
const commitConfig = gitzoneConfig.commit || {};
// Check flags and merge with config options if (mode.help || subcommand === "help") {
const wantsRelease = !!(argvArg.r || argvArg.release); showHelp(mode);
const wantsTest = !!(argvArg.t || argvArg.test || commitConfig.alwaysTest); return;
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);
}
} }
// Print execution plan at the start if (subcommand === "recommend") {
ui.printExecutionPlan({ await handleRecommend(mode);
autoAccept: !!(argvArg.y || argvArg.yes), return;
push: !!(argvArg.p || argvArg.push), }
test: wantsTest,
build: wantsBuild, if (mode.json) {
release: wantsRelease, printJson({
format: !!argvArg.format, ok: false,
registries: releaseConfig?.getRegistries(), error:
"JSON output is only supported for the read-only recommendation flow. Use `gitzone commit recommend --json`.",
}); });
return;
if (argvArg.format) {
const formatMod = await import('../mod_format/index.js');
await formatMod.run();
} }
// Run tests early to fail fast before analysis const workflow = await resolveCommitWorkflow(argvArg);
if (wantsTest) { if (workflow.releaseFlagRequested) {
ui.printHeader('🧪 Running tests...'); logger.log(
const smartshellForTest = new plugins.smartshell.Smartshell({ "warn",
executor: 'bash', "`gitzone commit -r` is deprecated and no longer releases. Use `gitzone release` after committing.",
);
}
printCommitExecutionPlan(workflow);
if (workflow.confirmation === "plan") {
return;
}
const smartshellInstance = new plugins.smartshell.Smartshell({
executor: "bash",
sourceFilePaths: [], sourceFilePaths: [],
}); });
const testResult = await smartshellForTest.exec('pnpm test');
if (testResult.exitCode !== 0) { let nextCommitObject: any;
logger.log('error', 'Tests failed. Aborting commit.'); 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;
}
}
const commitShaResult = await smartshellInstance.exec("git rev-parse --short HEAD");
const currentBranch = await detectCurrentBranch(smartshellInstance);
ui.printSummary({
projectType: "source",
branch: currentBranch,
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); process.exit(1);
} }
logger.log('success', 'All tests passed.'); logger.log("success", `${label} passed.`);
} }
ui.printHeader('🔍 Analyzing repository changes...');
async function runAnalyzeStep(): Promise<any> {
ui.printHeader("Analyzing repository changes");
const aidoc = new plugins.tsdoc.AiDoc(); const aidoc = new plugins.tsdoc.AiDoc();
await aidoc.start(); await aidoc.start();
try {
const nextCommitObject = await aidoc.buildNextCommitObject(paths.cwd); const nextCommitObject = await aidoc.buildNextCommitObject(paths.cwd);
await aidoc.stop();
ui.printRecommendation({ ui.printRecommendation({
recommendedNextVersion: nextCommitObject.recommendedNextVersion, recommendedNextVersion: nextCommitObject.recommendedNextVersion,
recommendedNextVersionLevel: nextCommitObject.recommendedNextVersionLevel, recommendedNextVersionLevel: nextCommitObject.recommendedNextVersionLevel,
recommendedNextVersionScope: nextCommitObject.recommendedNextVersionScope, recommendedNextVersionScope: nextCommitObject.recommendedNextVersionScope,
recommendedNextVersionMessage: nextCommitObject.recommendedNextVersionMessage, recommendedNextVersionMessage: nextCommitObject.recommendedNextVersionMessage,
}); });
return nextCommitObject;
} finally {
await aidoc.stop();
}
}
let answerBucket: plugins.smartinteract.AnswerBucket; async function buildAnswerBucket(
nextCommitObject: any,
// Check if -y/--yes flag is set AND version is not a breaking change workflow: IResolvedCommitWorkflow,
// Breaking changes (major version bumps) always require manual confirmation mode: ICliMode,
const isBreakingChange = nextCommitObject.recommendedNextVersionLevel === 'BREAKING CHANGE'; argvArg: any,
const canAutoAccept = (argvArg.y || argvArg.yes) && !isBreakingChange; ): Promise<plugins.smartinteract.AnswerBucket> {
const isBreakingChange = nextCommitObject.recommendedNextVersionLevel === "BREAKING CHANGE";
const canAutoAccept = workflow.confirmation === "auto" && !isBreakingChange;
if (canAutoAccept) { if (canAutoAccept) {
// Auto-mode: create AnswerBucket programmatically logger.log("info", "Auto-accepting AI recommendations");
logger.log('info', '✓ Auto-accepting AI recommendations (--yes flag)'); return createAnswerBucket({
commitType: nextCommitObject.recommendedNextVersionLevel,
answerBucket = new plugins.smartinteract.AnswerBucket(); commitScope: nextCommitObject.recommendedNextVersionScope,
answerBucket.addAnswer({ commitDescription: nextCommitObject.recommendedNextVersionMessage,
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
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(); const commitInteract = new plugins.smartinteract.SmartInteract();
commitInteract.addQuestions([ commitInteract.addQuestions([
{ {
type: 'list', type: "list",
name: `commitType`, name: "commitType",
message: `Choose TYPE of the commit:`, message: "Choose TYPE of the commit:",
choices: [`fix`, `feat`, `BREAKING CHANGE`], choices: ["fix", "feat", "BREAKING CHANGE"],
default: nextCommitObject.recommendedNextVersionLevel, default: nextCommitObject.recommendedNextVersionLevel,
}, },
{ {
type: 'input', type: "input",
name: `commitScope`, name: "commitScope",
message: `What is the SCOPE of the commit:`, message: "What is the SCOPE of the commit:",
default: nextCommitObject.recommendedNextVersionScope, default: nextCommitObject.recommendedNextVersionScope,
}, },
{ {
type: `input`, type: "input",
name: `commitDescription`, name: "commitDescription",
message: `What is the DESCRIPTION of the commit?`, message: "What is the DESCRIPTION of the commit?",
default: nextCommitObject.recommendedNextVersionMessage, 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(); 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); const commitString = createCommitStringFromAnswerBucket(answerBucket);
const commitVersionType = (() => {
switch (answerBucket.getAnswerFor('commitType')) {
case 'fix':
return 'patch';
case 'feat':
return 'minor';
case 'BREAKING CHANGE':
return 'major';
}
})();
ui.printHeader('✨ Creating Semantic Commit');
ui.printCommitMessage(commitString); ui.printCommitMessage(commitString);
const smartshellInstance = new plugins.smartshell.Smartshell({ await smartshellInstance.exec("git add -A");
executor: 'bash', const result = await smartshellInstance.exec(`git commit -m ${shellQuote(commitString)}`);
sourceFilePaths: [], if (result.exitCode !== 0) {
}); logger.log("error", "git commit failed.");
// 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); process.exit(1);
} }
} }
// Determine total steps based on options async function runPushStep(
// Note: test runs early (like format) so not counted in numbered steps smartshellInstance: plugins.smartshell.Smartshell,
const willPush = answerBucket.getAnswerFor('pushToOrigin') && !(process.env.CI === 'true'); workflow: IResolvedCommitWorkflow,
const willRelease = answerBucket.getAnswerFor('createRelease') && releaseConfig?.hasRegistries(); ): Promise<void> {
let totalSteps = 5; // Base steps: commitinfo, changelog, staging, commit, version const currentBranch = await detectCurrentBranch(smartshellInstance);
if (wantsBuild) totalSteps += 2; // build step + verification step const followTags = workflow.pushFollowTags ? " --follow-tags" : "";
if (willPush) totalSteps++; const result = await smartshellInstance.exec(
if (willRelease) totalSteps++; `git push ${workflow.pushRemote} ${currentBranch}${followTags}`,
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(); if (result.exitCode !== 0) {
ui.printStep(currentStep, totalSteps, '🔧 Baking commit info into code', 'done'); logger.log("error", "git push failed.");
// Step 2: Writing changelog
currentStep++;
ui.printStep(currentStep, totalSteps, '📄 Generating changelog.md', 'in-progress');
let changelog = nextCommitObject.changelog;
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); process.exit(1);
} }
ui.printStep(currentStep, totalSteps, '🔨 Running build', 'done'); }
// Step 7: Verify no uncommitted changes async function detectCurrentBranch(
currentStep++; smartshellInstance: plugins.smartshell.Smartshell,
ui.printStep(currentStep, totalSteps, '🔍 Verifying clean working tree', 'in-progress'); ): Promise<string> {
const statusResult = await smartshellInstance.exec('git status --porcelain'); const branchResult = await smartshellInstance.exec("git branch --show-current");
if (statusResult.stdout.trim() !== '') { return branchResult.stdout.trim() || "master";
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:'); function assertAnalysisComplete(
console.log(statusResult.stdout); answerBucket: plugins.smartinteract.AnswerBucket | undefined,
logger.log('error', 'Aborting release. Please ensure build artifacts are in .gitignore'); nextCommitObject: any,
process.exit(1); ): void {
} if (!answerBucket || !nextCommitObject) {
ui.printStep(currentStep, totalSteps, '🔍 Verifying clean working tree', 'done'); throw new Error("Commit workflow requires analyze before changelog and commit steps.");
} }
}
// Step: Push to remote (optional) function shellQuote(value: string): string {
const currentBranch = await helpers.detectCurrentBranch(); return `'${value.replaceAll("'", "'\\''")}'`;
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) function printCommitExecutionPlan(workflow: IResolvedCommitWorkflow): void {
let releasedRegistries: string[] = []; console.log("");
if (willRelease && releaseConfig) { console.log("gitzone commit - resolved workflow");
currentStep++; console.log(`confirmation: ${workflow.confirmation}`);
const registries = releaseConfig.getRegistries(); console.log(`steps: ${workflow.steps.join(" -> ")}`);
ui.printStep(currentStep, totalSteps, `📦 Publishing to ${registries.length} registr${registries.length === 1 ? 'y' : 'ies'}`, 'in-progress'); console.log(`changelog: ${workflow.changelogFile}#${workflow.changelogSection}`);
console.log("");
}
const accessLevel = releaseConfig.getAccessLevel(); async function handleRecommend(mode: ICliMode): Promise<void> {
for (const registry of registries) { const recommendationBuilder = async () => {
const aidoc = new plugins.tsdoc.AiDoc();
await aidoc.start();
try { try {
await smartshellInstance.exec(`npm publish --registry=${registry} --access=${accessLevel}`); return await aidoc.buildNextCommitObject(paths.cwd);
releasedRegistries.push(registry); } finally {
} catch (error) { await aidoc.stop();
logger.log('error', `Failed to publish to ${registry}: ${error}`);
} }
};
const recommendation = mode.json
? await runWithSuppressedOutput(recommendationBuilder)
: await recommendationBuilder();
if (mode.json) {
printJson(recommendation);
return;
} }
if (releasedRegistries.length === registries.length) { ui.printRecommendation({
ui.printStep(currentStep, totalSteps, `📦 Publishing to ${registries.length} registr${registries.length === 1 ? 'y' : 'ies'}`, 'done'); recommendedNextVersion: recommendation.recommendedNextVersion,
} else { recommendedNextVersionLevel: recommendation.recommendedNextVersionLevel,
ui.printStep(currentStep, totalSteps, `📦 Publishing to ${registries.length} registr${registries.length === 1 ? 'y' : 'ies'}`, 'error'); recommendedNextVersionScope: recommendation.recommendedNextVersionScope,
} recommendedNextVersionMessage: recommendation.recommendedNextVersionMessage,
}
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
ui.printSummary({
projectType,
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,
}); });
};
console.log(
`Suggested commit: ${recommendation.recommendedNextVersionLevel}(${recommendation.recommendedNextVersionScope}): ${recommendation.recommendedNextVersionMessage}`,
);
}
const createCommitStringFromAnswerBucket = ( const createCommitStringFromAnswerBucket = (
answerBucket: plugins.smartinteract.AnswerBucket, answerBucket: plugins.smartinteract.AnswerBucket,
) => { ) => {
const commitType = answerBucket.getAnswerFor('commitType'); const commitType = answerBucket.getAnswerFor("commitType");
const commitScope = answerBucket.getAnswerFor('commitScope'); const commitScope = answerBucket.getAnswerFor("commitScope");
const commitDescription = answerBucket.getAnswerFor('commitDescription'); const commitDescription = answerBucket.getAnswerFor("commitDescription");
return `${commitType}(${commitScope}): ${commitDescription}`; return `${commitType}(${commitScope}): ${commitDescription}`;
}; };
export function showHelp(mode?: ICliMode): void {
if (mode?.json) {
printJson({
command: "commit",
usage: "gitzone commit [recommend] [options]",
description: "Analyzes changes and creates one semantic source commit.",
commands: [
{
name: "recommend",
description: "Generate a commit recommendation without mutating the repository",
},
],
flags: [
{ 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 -ytbp",
"gitzone release",
],
});
return;
}
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("");
console.log("Flags:");
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 -ytbp");
console.log(" gitzone release");
console.log("");
}
+28 -16
View File
@@ -63,7 +63,7 @@ export async function detectProjectType(): Promise<ProjectType> {
* @param versionType Type of version bump * @param versionType Type of version bump
* @returns New version string * @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+)/); const versionMatch = currentVersion.match(/^(\d+)\.(\d+)\.(\d+)/);
if (!versionMatch) { if (!versionMatch) {
@@ -95,7 +95,7 @@ function calculateNewVersion(currentVersion: string, versionType: VersionType):
* @param projectType The project type to determine which file to read * @param projectType The project type to determine which file to read
* @returns The current version string * @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') { if (projectType === 'npm' || projectType === 'both') {
const packageJsonPath = plugins.path.join(paths.cwd, 'package.json'); const packageJsonPath = plugins.path.join(paths.cwd, 'package.json');
const content = (await plugins.smartfs const content = (await plugins.smartfs
@@ -128,7 +128,7 @@ async function readCurrentVersion(projectType: ProjectType): Promise<string> {
* @param filePath Path to the JSON file * @param filePath Path to the JSON file
* @param newVersion The new version to write * @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 const content = (await plugins.smartfs
.file(filePath) .file(filePath)
.encoding('utf8') .encoding('utf8')
@@ -141,6 +141,30 @@ async function updateVersionFile(filePath: string, newVersion: string): Promise<
.write(JSON.stringify(config, null, 2) + '\n'); .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 * Bumps the project version based on project type
* Handles npm-only, deno-only, and dual projects with unified logic * 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}`); logger.log('info', `Bumping version: ${currentVersion}${newVersion}`);
// 3. Determine which files to update // 3. Determine which files to update
const filesToUpdate: string[] = []; const filesToUpdate = await updateProjectVersionFiles(projectType, newVersion);
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');
}
// 4. Stage all updated files // 4. Stage all updated files
await smartshellInstance.exec(`git add ${filesToUpdate.join(' ')}`); await smartshellInstance.exec(`git add ${filesToUpdate.join(' ')}`);
+7 -2
View File
@@ -10,7 +10,7 @@ interface ICommitSummary {
commitType: string; commitType: string;
commitScope: string; commitScope: string;
commitMessage: string; commitMessage: string;
newVersion: string; newVersion?: string;
commitSha?: string; commitSha?: string;
pushed: boolean; pushed: boolean;
repoUrl?: string; repoUrl?: string;
@@ -197,9 +197,14 @@ export function printSummary(summary: ICommitSummary): void {
`Branch: 🌿 ${summary.branch}`, `Branch: 🌿 ${summary.branch}`,
`Commit Type: ${getCommitTypeEmoji(summary.commitType)}`, `Commit Type: ${getCommitTypeEmoji(summary.commitType)}`,
`Scope: 📍 ${summary.commitScope}`, `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) { if (summary.commitSha) {
lines.push(`Commit SHA: 📌 ${summary.commitSha}`); lines.push(`Commit SHA: 📌 ${summary.commitSha}`);
} }
+33 -3
View File
@@ -3,6 +3,8 @@ import * as plugins from './mod.plugins.js';
export interface ICommitConfig { export interface ICommitConfig {
alwaysTest: boolean; alwaysTest: boolean;
alwaysBuild: boolean; alwaysBuild: boolean;
confirmation: 'prompt' | 'auto' | 'plan';
steps: string[];
} }
/** /**
@@ -15,7 +17,7 @@ export class CommitConfig {
constructor(cwd: string = process.cwd()) { constructor(cwd: string = process.cwd()) {
this.cwd = 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 smartconfigInstance = new plugins.smartconfig.Smartconfig(this.cwd);
const gitzoneConfig = smartconfigInstance.dataFor<any>('@git.zone/cli', {}); const gitzoneConfig = smartconfigInstance.dataFor<any>('@git.zone/cli', {});
const alwaysTest = gitzoneConfig?.commit?.alwaysTest ?? false;
const alwaysBuild = gitzoneConfig?.commit?.alwaysBuild ?? false;
this.config = { this.config = {
alwaysTest: gitzoneConfig?.commit?.alwaysTest ?? false, alwaysTest,
alwaysBuild: gitzoneConfig?.commit?.alwaysBuild ?? false, 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 // Update commit settings
smartconfigData['@git.zone/cli'].commit.alwaysTest = this.config.alwaysTest; smartconfigData['@git.zone/cli'].commit.alwaysTest = this.config.alwaysTest;
smartconfigData['@git.zone/cli'].commit.alwaysBuild = this.config.alwaysBuild; 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 // Write back to file
await plugins.smartfs await plugins.smartfs
@@ -101,4 +115,20 @@ export class CommitConfig {
public setAlwaysBuild(value: boolean): void { public setAlwaysBuild(value: boolean): void {
this.config.alwaysBuild = value; 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];
}
} }
+14 -7
View File
@@ -35,13 +35,11 @@ export class ReleaseConfig {
public async load(): Promise<void> { public async load(): Promise<void> {
const smartconfigInstance = new plugins.smartconfig.Smartconfig(this.cwd); const smartconfigInstance = new plugins.smartconfig.Smartconfig(this.cwd);
const gitzoneConfig = smartconfigInstance.dataFor<any>('@git.zone/cli', {}); const gitzoneConfig = smartconfigInstance.dataFor<any>('@git.zone/cli', {});
const npmTarget = gitzoneConfig?.release?.targets?.npm || {};
// Also check szci for backward compatibility
const szciConfig = smartconfigInstance.dataFor<any>('@ship.zone/szci', {});
this.config = { this.config = {
registries: gitzoneConfig?.release?.registries || [], registries: npmTarget.registries || [],
accessLevel: gitzoneConfig?.release?.accessLevel || szciConfig?.npmAccessLevel || 'public', accessLevel: npmTarget.accessLevel || 'public',
}; };
} }
@@ -68,9 +66,18 @@ export class ReleaseConfig {
smartconfigData['@git.zone/cli'].release = {}; 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 // Update registries and accessLevel
smartconfigData['@git.zone/cli'].release.registries = this.config.registries; smartconfigData['@git.zone/cli'].release.targets.npm.enabled = this.config.registries.length > 0;
smartconfigData['@git.zone/cli'].release.accessLevel = this.config.accessLevel; 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 // Write back to file
await plugins.smartfs await plugins.smartfs
+554 -185
View File
@@ -1,73 +1,123 @@
// gitzone config - manage release registry configuration // gitzone config - manage CLI smartconfig configuration
import * as plugins from './mod.plugins.js'; import * as plugins from "./mod.plugins.js";
import { ReleaseConfig } from './classes.releaseconfig.js'; import { ReleaseConfig } from "./classes.releaseconfig.js";
import { CommitConfig } from './classes.commitconfig.js'; import { CommitConfig } from "./classes.commitconfig.js";
import { runFormatter, type ICheckResult } from '../mod_format/index.js'; import { runFormatter, type ICheckResult } from "../mod_format/index.js";
import type { ICliMode } from "../helpers.climode.js";
import { getCliMode, printJson } from "../helpers.climode.js";
import {
getCliConfigValueFromData,
readSmartconfigFile,
setCliConfigValueInData,
unsetCliConfigValueInData,
writeSmartconfigFile,
} from "../helpers.smartconfig.js";
import {
CURRENT_GITZONE_CLI_SCHEMA_VERSION,
migrateSmartconfigData,
} from "../helpers.smartconfigmigrations.js";
export { ReleaseConfig, CommitConfig }; export { ReleaseConfig, CommitConfig };
const defaultCliMode: ICliMode = {
output: "human",
interactive: true,
json: false,
plain: false,
quiet: false,
yes: false,
help: false,
agent: false,
checkUpdates: true,
isTty: true,
};
/** /**
* Format .smartconfig.json with diff preview * Format .smartconfig.json with diff preview
* Shows diff first, asks for confirmation, then applies * Shows diff first, asks for confirmation, then applies
*/ */
async function formatSmartconfigWithDiff(): Promise<void> { async function formatSmartconfigWithDiff(mode: ICliMode): Promise<void> {
if (!mode.interactive) {
return;
}
// Check for diffs first // Check for diffs first
const checkResult = await runFormatter('smartconfig', { const checkResult = (await runFormatter("smartconfig", {
checkOnly: true, checkOnly: true,
showDiff: true, showDiff: true,
}) as ICheckResult | void; })) as ICheckResult | void;
if (checkResult && checkResult.hasDiff) { if (checkResult && checkResult.hasDiff) {
const shouldApply = await plugins.smartinteract.SmartInteract.getCliConfirmation( const shouldApply =
'Apply formatting changes to .smartconfig.json?', await plugins.smartinteract.SmartInteract.getCliConfirmation(
true "Apply formatting changes to .smartconfig.json?",
true,
); );
if (shouldApply) { if (shouldApply) {
await runFormatter('smartconfig', { silent: true }); await runFormatter("smartconfig", { silent: true });
} }
} }
} }
export const run = async (argvArg: any) => { export const run = async (argvArg: any) => {
const mode = await getCliMode(argvArg);
const command = argvArg._?.[1]; const command = argvArg._?.[1];
const value = argvArg._?.[2]; const value = argvArg._?.[2];
if (mode.help || command === "help") {
showHelp(mode);
return;
}
// If no command provided, show interactive menu // If no command provided, show interactive menu
if (!command) { if (!command) {
if (!mode.interactive) {
showHelp(mode);
return;
}
await handleInteractiveMenu(); await handleInteractiveMenu();
return; return;
} }
switch (command) { switch (command) {
case 'show': case "show":
await handleShow(); await handleShow(mode);
break; break;
case 'add': case "add":
await handleAdd(value); await handleAdd(value, mode);
break; break;
case 'remove': case "remove":
await handleRemove(value); await handleRemove(value, mode);
break; break;
case 'clear': case "clear":
await handleClear(); await handleClear(mode);
break; break;
case 'access': case "access":
case 'accessLevel': case "accessLevel":
await handleAccessLevel(value); await handleAccessLevel(value, mode);
break; break;
case 'commit': case "commit":
await handleCommit(argvArg._?.[2], argvArg._?.[3]); await handleCommit(argvArg._?.[2], argvArg._?.[3], mode);
break; break;
case 'services': case "services":
await handleServices(); await handleServices(mode);
break; break;
case 'help': case "migrate":
showHelp(); await handleMigrate(value, mode);
break;
case "get":
await handleGet(value, mode);
break;
case "set":
await handleSet(value, argvArg._?.[3], mode);
break;
case "unset":
await handleUnset(value, mode);
break; break;
default: default:
plugins.logger.log('error', `Unknown command: ${command}`); plugins.logger.log("error", `Unknown command: ${command}`);
showHelp(); showHelp(mode);
} }
}; };
@@ -75,55 +125,65 @@ export const run = async (argvArg: any) => {
* Interactive menu for config command * Interactive menu for config command
*/ */
async function handleInteractiveMenu(): Promise<void> { async function handleInteractiveMenu(): Promise<void> {
console.log(''); console.log("");
console.log('╭─────────────────────────────────────────────────────────────╮'); console.log(
console.log('│ gitzone config - Project Configuration │'); "╭─────────────────────────────────────────────────────────────╮",
console.log('╰─────────────────────────────────────────────────────────────╯'); );
console.log(''); console.log(
"│ gitzone config - Project Configuration │",
);
console.log(
"╰─────────────────────────────────────────────────────────────╯",
);
console.log("");
const interactInstance = new plugins.smartinteract.SmartInteract(); const interactInstance = new plugins.smartinteract.SmartInteract();
const response = await interactInstance.askQuestion({ const response = await interactInstance.askQuestion({
type: 'list', type: "list",
name: 'action', name: "action",
message: 'What would you like to do?', message: "What would you like to do?",
default: 'show', default: "show",
choices: [ choices: [
{ name: 'Show current configuration', value: 'show' }, { name: "Show current configuration", value: "show" },
{ name: 'Add a registry', value: 'add' }, { name: "Add an npm target registry", value: "add" },
{ name: 'Remove a registry', value: 'remove' }, { name: "Remove an npm target registry", value: "remove" },
{ name: 'Clear all registries', value: 'clear' }, { name: "Clear npm target registries", value: "clear" },
{ name: 'Set access level (public/private)', value: 'access' }, { name: "Set access level (public/private)", value: "access" },
{ name: 'Configure commit options', value: 'commit' }, { name: "Migrate smartconfig schema", value: "migrate" },
{ name: 'Configure services', value: 'services' }, { name: "Configure commit options", value: "commit" },
{ name: 'Show help', value: 'help' }, { name: "Configure services", value: "services" },
{ name: "Show help", value: "help" },
], ],
}); });
const action = (response as any).value; const action = (response as any).value;
switch (action) { switch (action) {
case 'show': case "show":
await handleShow(); await handleShow(defaultCliMode);
break; break;
case 'add': case "add":
await handleAdd(); await handleAdd(undefined, defaultCliMode);
break; break;
case 'remove': case "remove":
await handleRemove(); await handleRemove(undefined, defaultCliMode);
break; break;
case 'clear': case "clear":
await handleClear(); await handleClear(defaultCliMode);
break; break;
case 'access': case "access":
await handleAccessLevel(); await handleAccessLevel(undefined, defaultCliMode);
break; break;
case 'commit': case "migrate":
await handleCommit(); await handleMigrate(undefined, defaultCliMode);
break; break;
case 'services': case "commit":
await handleServices(); await handleCommit(undefined, undefined, defaultCliMode);
break; break;
case 'help': case "services":
await handleServices(defaultCliMode);
break;
case "help":
showHelp(); showHelp();
break; break;
} }
@@ -132,50 +192,69 @@ async function handleInteractiveMenu(): Promise<void> {
/** /**
* Show current registry configuration * Show current registry configuration
*/ */
async function handleShow(): Promise<void> { async function handleShow(mode: ICliMode): Promise<void> {
if (mode.json) {
const smartconfigData = await readSmartconfigFile();
printJson(getCliConfigValueFromData(smartconfigData, ""));
return;
}
const config = await ReleaseConfig.fromCwd(); const config = await ReleaseConfig.fromCwd();
const registries = config.getRegistries(); const registries = config.getRegistries();
const accessLevel = config.getAccessLevel(); const accessLevel = config.getAccessLevel();
console.log(''); console.log("");
console.log('╭─────────────────────────────────────────────────────────────╮'); console.log(
console.log('│ Release Configuration │'); "╭─────────────────────────────────────────────────────────────╮",
console.log('╰─────────────────────────────────────────────────────────────╯'); );
console.log(''); console.log(
"│ Release NPM Target Configuration │",
);
console.log(
"╰─────────────────────────────────────────────────────────────╯",
);
console.log("");
// Show access level // Show access level
plugins.logger.log('info', `Access Level: ${accessLevel}`); plugins.logger.log("info", `Access Level: ${accessLevel}`);
console.log(''); console.log("");
if (registries.length === 0) { 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("");
console.log(' Run `gitzone config add <registry-url>` to add one.'); console.log(" Run `gitzone config add <registry-url>` to add one.");
console.log(''); console.log("");
} else { } else {
plugins.logger.log('info', `Configured registries (${registries.length}):`); plugins.logger.log("info", `Configured npm target registries (${registries.length}):`);
console.log(''); console.log("");
registries.forEach((url, index) => { registries.forEach((url, index) => {
console.log(` ${index + 1}. ${url}`); console.log(` ${index + 1}. ${url}`);
}); });
console.log(''); console.log("");
} }
} }
/** /**
* Add a registry URL * Add an npm target registry URL
*/ */
async function handleAdd(url?: string): Promise<void> { async function handleAdd(
url: string | undefined,
mode: ICliMode,
): Promise<void> {
if (!url) { if (!url) {
if (!mode.interactive) {
throw new Error("Registry URL is required in non-interactive mode");
}
// Interactive mode // Interactive mode
const interactInstance = new plugins.smartinteract.SmartInteract(); const interactInstance = new plugins.smartinteract.SmartInteract();
const response = await interactInstance.askQuestion({ const response = await interactInstance.askQuestion({
type: 'input', type: "input",
name: 'registryUrl', name: "registryUrl",
message: 'Enter registry URL:', message: "Enter npm target registry URL:",
default: 'https://registry.npmjs.org', default: "https://registry.npmjs.org",
validate: (input: string) => { validate: (input: string) => {
return !!(input && input.trim() !== ''); return !!(input && input.trim() !== "");
}, },
}); });
url = (response as any).value; url = (response as any).value;
@@ -186,32 +265,48 @@ async function handleAdd(url?: string): Promise<void> {
if (added) { if (added) {
await config.save(); await config.save();
plugins.logger.log('success', `Added registry: ${url}`); if (mode.json) {
await formatSmartconfigWithDiff(); printJson({
ok: true,
action: "add",
registry: url,
registries: config.getRegistries(),
});
return;
}
plugins.logger.log("success", `Added npm target registry: ${url}`);
await formatSmartconfigWithDiff(mode);
} else { } else {
plugins.logger.log('warn', `Registry already exists: ${url}`); plugins.logger.log("warn", `Registry already exists: ${url}`);
} }
} }
/** /**
* Remove a registry URL * Remove an npm target registry URL
*/ */
async function handleRemove(url?: string): Promise<void> { async function handleRemove(
url: string | undefined,
mode: ICliMode,
): Promise<void> {
const config = await ReleaseConfig.fromCwd(); const config = await ReleaseConfig.fromCwd();
const registries = config.getRegistries(); const registries = config.getRegistries();
if (registries.length === 0) { 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; return;
} }
if (!url) { if (!url) {
if (!mode.interactive) {
throw new Error("Registry URL is required in non-interactive mode");
}
// Interactive mode - show list to select from // Interactive mode - show list to select from
const interactInstance = new plugins.smartinteract.SmartInteract(); const interactInstance = new plugins.smartinteract.SmartInteract();
const response = await interactInstance.askQuestion({ const response = await interactInstance.askQuestion({
type: 'list', type: "list",
name: 'registryUrl', name: "registryUrl",
message: 'Select registry to remove:', message: "Select npm target registry to remove:",
choices: registries, choices: registries,
default: registries[0], default: registries[0],
}); });
@@ -222,99 +317,135 @@ async function handleRemove(url?: string): Promise<void> {
if (removed) { if (removed) {
await config.save(); await config.save();
plugins.logger.log('success', `Removed registry: ${url}`); if (mode.json) {
await formatSmartconfigWithDiff(); printJson({
ok: true,
action: "remove",
registry: url,
registries: config.getRegistries(),
});
return;
}
plugins.logger.log("success", `Removed npm target registry: ${url}`);
await formatSmartconfigWithDiff(mode);
} else { } else {
plugins.logger.log('warn', `Registry not found: ${url}`); plugins.logger.log("warn", `Registry not found: ${url}`);
} }
} }
/** /**
* Clear all registries * Clear all npm target registries
*/ */
async function handleClear(): Promise<void> { async function handleClear(mode: ICliMode): Promise<void> {
const config = await ReleaseConfig.fromCwd(); const config = await ReleaseConfig.fromCwd();
if (!config.hasRegistries()) { if (!config.hasRegistries()) {
plugins.logger.log('info', 'No registries to clear.'); plugins.logger.log("info", "No npm target registries to clear.");
return; return;
} }
// Confirm before clearing // Confirm before clearing
const confirmed = await plugins.smartinteract.SmartInteract.getCliConfirmation( const confirmed = mode.interactive
'Clear all configured registries?', ? await plugins.smartinteract.SmartInteract.getCliConfirmation(
false "Clear all configured npm target registries?",
); false,
)
: true;
if (confirmed) { if (confirmed) {
config.clearRegistries(); config.clearRegistries();
await config.save(); await config.save();
plugins.logger.log('success', 'All registries cleared.'); if (mode.json) {
await formatSmartconfigWithDiff(); printJson({ ok: true, action: "clear", registries: [] });
return;
}
plugins.logger.log("success", "All npm target registries cleared.");
await formatSmartconfigWithDiff(mode);
} else { } else {
plugins.logger.log('info', 'Operation cancelled.'); plugins.logger.log("info", "Operation cancelled.");
} }
} }
/** /**
* Set or toggle access level * Set or toggle access level
*/ */
async function handleAccessLevel(level?: string): Promise<void> { async function handleAccessLevel(
level: string | undefined,
mode: ICliMode,
): Promise<void> {
const config = await ReleaseConfig.fromCwd(); const config = await ReleaseConfig.fromCwd();
const currentLevel = config.getAccessLevel(); const currentLevel = config.getAccessLevel();
if (!level) { if (!level) {
if (!mode.interactive) {
throw new Error("Access level is required in non-interactive mode");
}
// Interactive mode - toggle or ask // Interactive mode - toggle or ask
const interactInstance = new plugins.smartinteract.SmartInteract(); const interactInstance = new plugins.smartinteract.SmartInteract();
const response = await interactInstance.askQuestion({ const response = await interactInstance.askQuestion({
type: 'list', type: "list",
name: 'accessLevel', name: "accessLevel",
message: 'Select npm access level for publishing:', message: "Select npm access level for publishing:",
choices: ['public', 'private'], choices: ["public", "private"],
default: currentLevel, default: currentLevel,
}); });
level = (response as any).value; level = (response as any).value;
} }
// Validate the level // Validate the level
if (level !== 'public' && level !== 'private') { if (level !== "public" && level !== "private") {
plugins.logger.log('error', `Invalid access level: ${level}. Must be 'public' or 'private'.`); plugins.logger.log(
"error",
`Invalid access level: ${level}. Must be 'public' or 'private'.`,
);
return; return;
} }
if (level === currentLevel) { if (level === currentLevel) {
plugins.logger.log('info', `Access level is already set to: ${level}`); plugins.logger.log("info", `Access level is already set to: ${level}`);
return; return;
} }
config.setAccessLevel(level as 'public' | 'private'); config.setAccessLevel(level as "public" | "private");
await config.save(); await config.save();
plugins.logger.log('success', `Access level set to: ${level}`); if (mode.json) {
await formatSmartconfigWithDiff(); printJson({ ok: true, action: "access", accessLevel: level });
return;
}
plugins.logger.log("success", `Access level set to: ${level}`);
await formatSmartconfigWithDiff(mode);
} }
/** /**
* Handle commit configuration * Handle commit configuration
*/ */
async function handleCommit(setting?: string, value?: string): Promise<void> { async function handleCommit(
setting: string | undefined,
value: string | undefined,
mode: ICliMode,
): Promise<void> {
const config = await CommitConfig.fromCwd(); const config = await CommitConfig.fromCwd();
// No setting = interactive mode // No setting = interactive mode
if (!setting) { if (!setting) {
if (!mode.interactive) {
throw new Error("Commit setting is required in non-interactive mode");
}
await handleCommitInteractive(config); await handleCommitInteractive(config);
return; return;
} }
// Direct setting // Direct setting
switch (setting) { switch (setting) {
case 'alwaysTest': case "alwaysTest":
await handleCommitSetting(config, 'alwaysTest', value); await handleCommitSetting(config, "alwaysTest", value, mode);
break; break;
case 'alwaysBuild': case "alwaysBuild":
await handleCommitSetting(config, 'alwaysBuild', value); await handleCommitSetting(config, "alwaysBuild", value, mode);
break; break;
default: default:
plugins.logger.log('error', `Unknown commit setting: ${setting}`); plugins.logger.log("error", `Unknown commit setting: ${setting}`);
showCommitHelp(); showCommitHelp();
} }
} }
@@ -323,109 +454,347 @@ async function handleCommit(setting?: string, value?: string): Promise<void> {
* Interactive commit configuration * Interactive commit configuration
*/ */
async function handleCommitInteractive(config: CommitConfig): Promise<void> { async function handleCommitInteractive(config: CommitConfig): Promise<void> {
console.log(''); console.log("");
console.log('╭─────────────────────────────────────────────────────────────╮'); console.log(
console.log('│ Commit Configuration │'); "╭─────────────────────────────────────────────────────────────╮",
console.log('╰─────────────────────────────────────────────────────────────╯'); );
console.log(''); console.log(
"│ Commit Configuration │",
);
console.log(
"╰─────────────────────────────────────────────────────────────╯",
);
console.log("");
const interactInstance = new plugins.smartinteract.SmartInteract(); const interactInstance = new plugins.smartinteract.SmartInteract();
const response = await interactInstance.askQuestion({ const response = await interactInstance.askQuestion({
type: 'checkbox', type: "checkbox",
name: 'commitOptions', name: "commitOptions",
message: 'Select commit options to enable:', message: "Select commit options to enable:",
choices: [ choices: [
{ name: 'Always run tests before commit (-t)', value: 'alwaysTest' }, { name: "Always run tests before commit (-t)", value: "alwaysTest" },
{ name: 'Always build after commit (-b)', value: 'alwaysBuild' }, { name: "Always build after commit (-b)", value: "alwaysBuild" },
], ],
default: [ default: [
...(config.getAlwaysTest() ? ['alwaysTest'] : []), ...(config.getAlwaysTest() ? ["alwaysTest"] : []),
...(config.getAlwaysBuild() ? ['alwaysBuild'] : []), ...(config.getAlwaysBuild() ? ["alwaysBuild"] : []),
], ],
}); });
const selected = (response as any).value || []; const selected = (response as any).value || [];
config.setAlwaysTest(selected.includes('alwaysTest')); config.setAlwaysTest(selected.includes("alwaysTest"));
config.setAlwaysBuild(selected.includes('alwaysBuild')); config.setAlwaysBuild(selected.includes("alwaysBuild"));
syncCommitStepsFromBooleans(config);
await config.save(); await config.save();
plugins.logger.log('success', 'Commit configuration updated'); plugins.logger.log("success", "Commit configuration updated");
await formatSmartconfigWithDiff(); await formatSmartconfigWithDiff(defaultCliMode);
} }
/** /**
* Set a specific commit setting * Set a specific commit setting
*/ */
async function handleCommitSetting(config: CommitConfig, setting: string, value?: string): Promise<void> { async function handleCommitSetting(
config: CommitConfig,
setting: string,
value: string | undefined,
mode: ICliMode,
): Promise<void> {
// Parse boolean value // Parse boolean value
const boolValue = value === 'true' || value === '1' || value === 'on'; const boolValue = value === "true" || value === "1" || value === "on";
if (setting === 'alwaysTest') { if (setting === "alwaysTest") {
config.setAlwaysTest(boolValue); config.setAlwaysTest(boolValue);
} else if (setting === 'alwaysBuild') { } else if (setting === "alwaysBuild") {
config.setAlwaysBuild(boolValue); config.setAlwaysBuild(boolValue);
} }
syncCommitStepsFromBooleans(config);
await config.save(); await config.save();
plugins.logger.log('success', `Set ${setting} to ${boolValue}`); if (mode.json) {
await formatSmartconfigWithDiff(); printJson({ ok: true, action: "commit", setting, value: boolValue });
return;
}
plugins.logger.log("success", `Set ${setting} to ${boolValue}`);
await formatSmartconfigWithDiff(mode);
}
function syncCommitStepsFromBooleans(config: CommitConfig): void {
config.setSteps([
"analyze",
...(config.getAlwaysTest() ? ["test"] : []),
...(config.getAlwaysBuild() ? ["build"] : []),
"changelog",
"commit",
]);
} }
/** /**
* Show help for commit subcommand * Show help for commit subcommand
*/ */
function showCommitHelp(): void { function showCommitHelp(): void {
console.log(''); console.log("");
console.log('Usage: gitzone config commit [setting] [value]'); console.log("Usage: gitzone config commit [setting] [value]");
console.log(''); console.log("");
console.log('Settings:'); console.log("Settings:");
console.log(' alwaysTest [true|false] Always run tests before commit'); console.log(" alwaysTest [true|false] Always run tests before commit");
console.log(' alwaysBuild [true|false] Always build after commit'); console.log(" alwaysBuild [true|false] Always build after commit");
console.log(''); console.log("");
console.log('Examples:'); console.log("Examples:");
console.log(' gitzone config commit # Interactive mode'); console.log(" gitzone config commit # Interactive mode");
console.log(' gitzone config commit alwaysTest true'); console.log(" gitzone config commit alwaysTest true");
console.log(' gitzone config commit alwaysBuild false'); console.log(" gitzone config commit alwaysBuild false");
console.log(''); console.log("");
} }
/** /**
* Handle services configuration * Handle services configuration
*/ */
async function handleServices(): Promise<void> { async function handleServices(mode: ICliMode): Promise<void> {
if (!mode.interactive) {
throw new Error(
"Use `gitzone services config --json` or `gitzone services set ...` in non-interactive mode",
);
}
// Import and use ServiceManager's configureServices // Import and use ServiceManager's configureServices
const { ServiceManager } = await import('../mod_services/classes.servicemanager.js'); const { ServiceManager } =
await import("../mod_services/classes.servicemanager.js");
const serviceManager = new ServiceManager(); const serviceManager = new ServiceManager();
await serviceManager.init(); await serviceManager.init();
await serviceManager.configureServices(); await serviceManager.configureServices();
} }
async function handleGet(
configPath: string | undefined,
mode: ICliMode,
): Promise<void> {
if (!configPath) {
throw new Error("Configuration path is required");
}
const smartconfigData = await readSmartconfigFile();
const value = getCliConfigValueFromData(smartconfigData, configPath);
if (mode.json) {
printJson({ path: configPath, value, exists: value !== undefined });
return;
}
if (value === undefined) {
plugins.logger.log("warn", `No value set for ${configPath}`);
return;
}
if (typeof value === "string") {
console.log(value);
return;
}
printJson(value);
}
async function handleSet(
configPath: string | undefined,
rawValue: string | undefined,
mode: ICliMode,
): Promise<void> {
if (!configPath) {
throw new Error("Configuration path is required");
}
if (rawValue === undefined) {
throw new Error("Configuration value is required");
}
const smartconfigData = await readSmartconfigFile();
const parsedValue = parseConfigValue(rawValue);
setCliConfigValueInData(smartconfigData, configPath, parsedValue);
await writeSmartconfigFile(smartconfigData);
if (mode.json) {
printJson({
ok: true,
action: "set",
path: configPath,
value: parsedValue,
});
return;
}
plugins.logger.log("success", `Set ${configPath}`);
}
async function handleUnset(
configPath: string | undefined,
mode: ICliMode,
): Promise<void> {
if (!configPath) {
throw new Error("Configuration path is required");
}
const smartconfigData = await readSmartconfigFile();
const removed = unsetCliConfigValueInData(smartconfigData, configPath);
if (!removed) {
if (mode.json) {
printJson({
ok: false,
action: "unset",
path: configPath,
removed: false,
});
return;
}
plugins.logger.log("warn", `No value set for ${configPath}`);
return;
}
await writeSmartconfigFile(smartconfigData);
if (mode.json) {
printJson({ ok: true, action: "unset", path: configPath, removed: true });
return;
}
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") {
return true;
}
if (trimmedValue === "false") {
return false;
}
if (trimmedValue === "null") {
return null;
}
if (/^-?\d+(\.\d+)?$/.test(trimmedValue)) {
return Number(trimmedValue);
}
if (
(trimmedValue.startsWith("{") && trimmedValue.endsWith("}")) ||
(trimmedValue.startsWith("[") && trimmedValue.endsWith("]")) ||
(trimmedValue.startsWith('"') && trimmedValue.endsWith('"'))
) {
return JSON.parse(trimmedValue);
}
return rawValue;
}
/** /**
* Show help for config command * Show help for config command
*/ */
function showHelp(): void { export function showHelp(mode?: ICliMode): void {
console.log(''); if (mode?.json) {
console.log('Usage: gitzone config <command> [options]'); printJson({
console.log(''); command: "config",
console.log('Commands:'); usage: "gitzone config <command> [options]",
console.log(' show Display current release configuration'); commands: [
console.log(' add [url] Add a registry URL'); {
console.log(' remove [url] Remove a registry URL'); name: "show",
console.log(' clear Clear all registries'); description: "Display current @git.zone/cli configuration",
console.log(' access [public|private] Set npm access level for publishing'); },
console.log(' commit [setting] [value] Configure commit options'); { name: "get <path>", description: "Read a single config value" },
console.log(' services Configure which services are enabled'); { name: "set <path> <value>", description: "Write a config value" },
console.log(''); { name: "unset <path>", description: "Delete a config value" },
console.log('Examples:'); { name: "add [url]", description: "Add an npm release target registry" },
console.log(' gitzone config show'); { name: "remove [url]", description: "Remove an npm release target registry" },
console.log(' gitzone config add https://registry.npmjs.org'); { name: "clear", description: "Clear npm release target registries" },
console.log(' gitzone config add https://verdaccio.example.com'); {
console.log(' gitzone config remove https://registry.npmjs.org'); name: "access [public|private]",
console.log(' gitzone config clear'); description: "Set npm target publish access level",
console.log(' gitzone config access public'); },
console.log(' gitzone config access private'); {
console.log(' gitzone config commit # Interactive'); name: "commit <setting> <value>",
console.log(' gitzone config commit alwaysTest true'); description: "Set commit defaults",
console.log(' gitzone config services # Interactive'); },
console.log(''); {
name: "migrate [version]",
description: "Run version-targeted .smartconfig.json migrations",
},
],
examples: [
"gitzone config show --json",
"gitzone config get release.targets.npm.accessLevel",
"gitzone config set cli.interactive false",
"gitzone config set cli.output json",
],
});
return;
}
console.log("");
console.log("Usage: gitzone config <command> [options]");
console.log("");
console.log("Commands:");
console.log(
" show Display current @git.zone/cli configuration",
);
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 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 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",
);
console.log("");
console.log("Examples:");
console.log(" gitzone config show");
console.log(" gitzone config show --json");
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");
console.log(" gitzone config add https://registry.npmjs.org");
console.log(" gitzone config add https://verdaccio.example.com");
console.log(" gitzone config remove https://registry.npmjs.org");
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");
console.log("");
} }
+20 -3
View File
@@ -1,14 +1,31 @@
import * as plugins from './mod.plugins.js'; import * as plugins from "./mod.plugins.js";
import { FormatStats } from './classes.formatstats.js'; import { FormatStats } from "./classes.formatstats.js";
interface IFormatContextOptions {
interactive?: boolean;
jsonOutput?: boolean;
}
export class FormatContext { export class FormatContext {
private formatStats: FormatStats; private formatStats: FormatStats;
private interactive: boolean;
private jsonOutput: boolean;
constructor() { constructor(options: IFormatContextOptions = {}) {
this.formatStats = new FormatStats(); this.formatStats = new FormatStats();
this.interactive = options.interactive ?? true;
this.jsonOutput = options.jsonOutput ?? false;
} }
getFormatStats(): FormatStats { getFormatStats(): FormatStats {
return this.formatStats; return this.formatStats;
} }
isInteractive(): boolean {
return this.interactive;
}
isJsonOutput(): boolean {
return this.jsonOutput;
}
} }
@@ -4,77 +4,6 @@ import * as plugins from '../mod.plugins.js';
import * as paths from '../../paths.js'; import * as paths from '../../paths.js';
import { logger, logVerbose } from '../../gitzone.logging.js'; import { logger, logVerbose } from '../../gitzone.logging.js';
/**
* Ensures a certain dependency exists or is excluded
*/
const ensureDependency = async (
packageJsonObject: any,
position: 'dep' | 'devDep' | 'everywhere',
constraint: 'exclude' | 'include' | 'latest',
dependencyArg: string,
): Promise<void> => {
// Parse package name and version, handling scoped packages like @scope/name@version
const isScoped = dependencyArg.startsWith('@');
const lastAtIndex = dependencyArg.lastIndexOf('@');
// For scoped packages, the version @ must come after the /
// For unscoped packages, any @ indicates a version
const hasVersion = isScoped
? lastAtIndex > dependencyArg.indexOf('/')
: lastAtIndex >= 0;
const packageName = hasVersion ? dependencyArg.slice(0, lastAtIndex) : dependencyArg;
const version = hasVersion ? dependencyArg.slice(lastAtIndex + 1) : 'latest';
const targetSections: string[] = [];
switch (position) {
case 'dep':
targetSections.push('dependencies');
break;
case 'devDep':
targetSections.push('devDependencies');
break;
case 'everywhere':
targetSections.push('dependencies', 'devDependencies');
break;
}
for (const section of targetSections) {
if (!packageJsonObject[section]) {
packageJsonObject[section] = {};
}
switch (constraint) {
case 'exclude':
delete packageJsonObject[section][packageName];
break;
case 'include':
if (!packageJsonObject[section][packageName]) {
packageJsonObject[section][packageName] =
version === 'latest' ? '^1.0.0' : version;
}
break;
case 'latest':
try {
const registry = new plugins.smartnpm.NpmRegistry();
const packageInfo = await registry.getPackageInfo(packageName);
const latestVersion = packageInfo['dist-tags'].latest;
packageJsonObject[section][packageName] = `^${latestVersion}`;
} catch (error) {
logVerbose(
`Could not fetch latest version for ${packageName}, using existing or default`,
);
if (!packageJsonObject[section][packageName]) {
packageJsonObject[section][packageName] =
version === 'latest' ? '^1.0.0' : version;
}
}
break;
}
}
};
export class PackageJsonFormatter extends BaseFormatter { export class PackageJsonFormatter extends BaseFormatter {
get name(): string { get name(): string {
return 'packagejson'; return 'packagejson';
@@ -141,11 +70,6 @@ export class PackageJsonFormatter extends BaseFormatter {
packageJson.scripts.build = `echo "Not needed for now"`; packageJson.scripts.build = `echo "Not needed for now"`;
} }
// Ensure buildDocs script exists
if (!packageJson.scripts.buildDocs) {
packageJson.scripts.buildDocs = `tsdoc`;
}
// Set files array // Set files array
packageJson.files = [ packageJson.files = [
'ts/**/*', 'ts/**/*',
@@ -160,21 +84,6 @@ export class PackageJsonFormatter extends BaseFormatter {
'readme.md', 'readme.md',
]; ];
// Handle dependencies
await ensureDependency(
packageJson,
'devDep',
'exclude',
'@push.rocks/tapbundle',
);
await ensureDependency(packageJson, 'devDep', 'latest', '@git.zone/tstest');
await ensureDependency(
packageJson,
'devDep',
'latest',
'@git.zone/tsbuild',
);
// Set pnpm overrides from assets // Set pnpm overrides from assets
try { try {
const overridesContent = (await plugins.smartfs const overridesContent = (await plugins.smartfs
+54 -141
View File
@@ -1,144 +1,53 @@
import { BaseFormatter } from '../classes.baseformatter.js'; import { BaseFormatter } from "../classes.baseformatter.js";
import type { IPlannedChange } from '../interfaces.format.js'; import type { IPlannedChange } from "../interfaces.format.js";
import * as plugins from '../mod.plugins.js'; import * as plugins from "../mod.plugins.js";
import { logger, logVerbose } from '../../gitzone.logging.js'; import { logger, logVerbose } from "../../gitzone.logging.js";
import { migrateSmartconfigData } from "../../helpers.smartconfigmigrations.js";
/** const CONFIG_FILE = ".smartconfig.json";
* 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;
};
// Config file names in priority order (newest → oldest)
const CONFIG_FILE_NAMES = ['.smartconfig.json', 'smartconfig.json', 'npmextra.json'];
const TARGET_CONFIG_FILE = '.smartconfig.json';
export class SmartconfigFormatter extends BaseFormatter { export class SmartconfigFormatter extends BaseFormatter {
get name(): string { get name(): string {
return 'smartconfig'; return "smartconfig";
}
/**
* Find the config file, checking in priority order.
* Returns the path and whether it needs renaming.
*/
private async findConfigFile(): Promise<{ path: string; needsRename: boolean } | null> {
for (const filename of CONFIG_FILE_NAMES) {
const exists = await plugins.smartfs.file(filename).exists();
if (exists) {
return {
path: filename,
needsRename: filename !== TARGET_CONFIG_FILE,
};
}
}
return null;
} }
async analyze(): Promise<IPlannedChange[]> { async analyze(): Promise<IPlannedChange[]> {
const changes: IPlannedChange[] = []; const changes: IPlannedChange[] = [];
const configFile = await this.findConfigFile(); // File rename (npmextra.json/smartconfig.json → .smartconfig.json)
if (!configFile) { // is handled by the orchestrator before analysis.
logVerbose('No config file found (.smartconfig.json, smartconfig.json, or npmextra.json), skipping'); // This formatter only operates on .smartconfig.json.
const exists = await plugins.smartfs.file(CONFIG_FILE).exists();
if (!exists) {
logVerbose(".smartconfig.json does not exist, skipping");
return changes; return changes;
} }
// Read current content
const currentContent = (await plugins.smartfs const currentContent = (await plugins.smartfs
.file(configFile.path) .file(CONFIG_FILE)
.encoding('utf8') .encoding("utf8")
.read()) as string; .read()) as string;
// Parse and apply migrations
const smartconfigJson = JSON.parse(currentContent); const smartconfigJson = JSON.parse(currentContent);
migrateNamespaceKeys(smartconfigJson);
migrateAccessLevel(smartconfigJson); migrateSmartconfigData(smartconfigJson);
// Ensure namespaces exist // Ensure namespaces exist
if (!smartconfigJson['@git.zone/cli']) { if (!smartconfigJson["@git.zone/cli"]) {
smartconfigJson['@git.zone/cli'] = {}; smartconfigJson["@git.zone/cli"] = {};
} }
if (!smartconfigJson['@ship.zone/szci']) { if (!smartconfigJson["@ship.zone/szci"]) {
smartconfigJson['@ship.zone/szci'] = {}; smartconfigJson["@ship.zone/szci"] = {};
} }
const newContent = JSON.stringify(smartconfigJson, null, 2); const newContent = JSON.stringify(smartconfigJson, null, 2);
// If file needs renaming, plan a create + delete if (newContent !== currentContent) {
if (configFile.needsRename) {
changes.push({ changes.push({
type: 'create', type: "modify",
path: TARGET_CONFIG_FILE, path: CONFIG_FILE,
module: this.name, module: this.name,
description: `Migrate ${configFile.path} to ${TARGET_CONFIG_FILE}`, description: "Migrate and format .smartconfig.json",
content: newContent,
});
changes.push({
type: 'delete',
path: configFile.path,
module: this.name,
description: `Remove old ${configFile.path}`,
});
} else if (newContent !== currentContent) {
// File is already .smartconfig.json, just needs content update
changes.push({
type: 'modify',
path: TARGET_CONFIG_FILE,
module: this.name,
description: 'Migrate and format .smartconfig.json',
content: newContent, content: newContent,
}); });
} }
@@ -147,32 +56,41 @@ export class SmartconfigFormatter extends BaseFormatter {
} }
async applyChange(change: IPlannedChange): Promise<void> { async applyChange(change: IPlannedChange): Promise<void> {
if (change.type === 'delete') { if (change.type !== "modify" || !change.content) return;
await this.deleteFile(change.path);
logger.log('info', `Removed old config file ${change.path}`);
return;
}
if (!change.content) return;
// Parse the content to check for missing required fields
const smartconfigJson = JSON.parse(change.content); const smartconfigJson = JSON.parse(change.content);
// Check for missing required module information
const expectedRepoInformation: string[] = [ const expectedRepoInformation: string[] = [
'projectType', "projectType",
'module.githost', "module.githost",
'module.gitscope', "module.gitscope",
'module.gitrepo', "module.gitrepo",
'module.description', "module.description",
'module.npmPackagename', "module.npmPackagename",
'module.license', "module.license",
]; ];
const interactInstance = new plugins.smartinteract.SmartInteract(); const interactInstance = new plugins.smartinteract.SmartInteract();
const missingRepoInformation = expectedRepoInformation.filter(
(expectedRepoInformationItem) => {
return !plugins.smartobject.smartGet(
smartconfigJson["@git.zone/cli"],
expectedRepoInformationItem,
);
},
);
if (missingRepoInformation.length > 0 && !this.context.isInteractive()) {
throw new Error(
`Missing required .smartconfig.json fields: ${missingRepoInformation.join(", ")}`,
);
}
for (const expectedRepoInformationItem of expectedRepoInformation) { for (const expectedRepoInformationItem of expectedRepoInformation) {
if ( if (
!plugins.smartobject.smartGet( !plugins.smartobject.smartGet(
smartconfigJson['@git.zone/cli'], smartconfigJson["@git.zone/cli"],
expectedRepoInformationItem, expectedRepoInformationItem,
) )
) { ) {
@@ -180,8 +98,8 @@ export class SmartconfigFormatter extends BaseFormatter {
{ {
message: `What is the value of ${expectedRepoInformationItem}`, message: `What is the value of ${expectedRepoInformationItem}`,
name: expectedRepoInformationItem, name: expectedRepoInformationItem,
type: 'input', type: "input",
default: 'undefined variable', default: "undefined variable",
}, },
]); ]);
} }
@@ -194,7 +112,7 @@ export class SmartconfigFormatter extends BaseFormatter {
); );
if (cliProvidedValue) { if (cliProvidedValue) {
plugins.smartobject.smartAdd( plugins.smartobject.smartAdd(
smartconfigJson['@git.zone/cli'], smartconfigJson["@git.zone/cli"],
expectedRepoInformationItem, expectedRepoInformationItem,
cliProvidedValue, cliProvidedValue,
); );
@@ -202,12 +120,7 @@ export class SmartconfigFormatter extends BaseFormatter {
} }
const finalContent = JSON.stringify(smartconfigJson, null, 2); const finalContent = JSON.stringify(smartconfigJson, null, 2);
if (change.type === 'create') {
await this.createFile(change.path, finalContent);
} else {
await this.modifyFile(change.path, finalContent); await this.modifyFile(change.path, finalContent);
} logger.log("info", "Updated .smartconfig.json");
logger.log('info', `Updated ${change.path}`);
} }
} }
+292 -79
View File
@@ -1,23 +1,60 @@
import * as plugins from './mod.plugins.js'; import * as plugins from "./mod.plugins.js";
import { Project } from '../classes.project.js'; import { Project } from "../classes.project.js";
import { FormatContext } from './classes.formatcontext.js'; import { FormatContext } from "./classes.formatcontext.js";
import { FormatPlanner } from './classes.formatplanner.js'; import { FormatPlanner } from "./classes.formatplanner.js";
import { BaseFormatter } from './classes.baseformatter.js'; import { BaseFormatter } from "./classes.baseformatter.js";
import { logger, setVerboseMode } from '../gitzone.logging.js'; import { logger, setVerboseMode } from "../gitzone.logging.js";
import type { ICliMode } from "../helpers.climode.js";
import {
getCliMode,
printJson,
runWithSuppressedOutput,
} from "../helpers.climode.js";
import { getCliConfigValue } from "../helpers.smartconfig.js";
import { CleanupFormatter } from './formatters/cleanup.formatter.js'; import { CleanupFormatter } from "./formatters/cleanup.formatter.js";
import { SmartconfigFormatter } from './formatters/smartconfig.formatter.js'; import { SmartconfigFormatter } from "./formatters/smartconfig.formatter.js";
import { LicenseFormatter } from './formatters/license.formatter.js'; import { LicenseFormatter } from "./formatters/license.formatter.js";
import { PackageJsonFormatter } from './formatters/packagejson.formatter.js'; import { PackageJsonFormatter } from "./formatters/packagejson.formatter.js";
import { TemplatesFormatter } from './formatters/templates.formatter.js'; import { TemplatesFormatter } from "./formatters/templates.formatter.js";
import { GitignoreFormatter } from './formatters/gitignore.formatter.js'; import { GitignoreFormatter } from "./formatters/gitignore.formatter.js";
import { TsconfigFormatter } from './formatters/tsconfig.formatter.js'; import { TsconfigFormatter } from "./formatters/tsconfig.formatter.js";
import { PrettierFormatter } from './formatters/prettier.formatter.js'; import { PrettierFormatter } from "./formatters/prettier.formatter.js";
import { ReadmeFormatter } from './formatters/readme.formatter.js'; import { ReadmeFormatter } from "./formatters/readme.formatter.js";
import { CopyFormatter } from './formatters/copy.formatter.js'; import { CopyFormatter } from "./formatters/copy.formatter.js";
/**
* Rename npmextra.json or smartconfig.json to .smartconfig.json
* before any formatter tries to read config.
*/
async function migrateConfigFile(allowWrite: boolean): Promise<void> {
const target = ".smartconfig.json";
const targetExists = await plugins.smartfs.file(target).exists();
if (targetExists) return;
for (const oldName of ["smartconfig.json", "npmextra.json"]) {
const exists = await plugins.smartfs.file(oldName).exists();
if (exists) {
if (!allowWrite) {
return;
}
const content = (await plugins.smartfs
.file(oldName)
.encoding("utf8")
.read()) as string;
await plugins.smartfs.file(`./${target}`).encoding("utf8").write(content);
await plugins.smartfs.file(oldName).delete();
logger.log("info", `Migrated ${oldName} to ${target}`);
return;
}
}
}
// Shared formatter class map used by both run() and runFormatter() // Shared formatter class map used by both run() and runFormatter()
const formatterMap: Record<string, new (ctx: FormatContext, proj: Project) => BaseFormatter> = { const formatterMap: Record<
string,
new (ctx: FormatContext, proj: Project) => BaseFormatter
> = {
cleanup: CleanupFormatter, cleanup: CleanupFormatter,
smartconfig: SmartconfigFormatter, smartconfig: SmartconfigFormatter,
license: LicenseFormatter, license: LicenseFormatter,
@@ -31,7 +68,104 @@ const formatterMap: Record<string, new (ctx: FormatContext, proj: Project) => Ba
}; };
// Formatters that don't require projectType to be set // Formatters that don't require projectType to be set
const formattersNotRequiringProjectType = ['smartconfig', 'prettier', 'cleanup', 'packagejson']; const formattersNotRequiringProjectType = [
"smartconfig",
"prettier",
"cleanup",
"packagejson",
];
const getFormatConfig = async () => {
const rawFormatConfig = await getCliConfigValue<Record<string, any>>(
"format",
{},
);
return {
interactive: true,
showDiffs: false,
autoApprove: false,
showStats: true,
modules: {
skip: [],
only: [],
...(rawFormatConfig.modules || {}),
},
...rawFormatConfig,
};
};
const createActiveFormatters = async (options: {
interactive: boolean;
jsonOutput: boolean;
}) => {
const project = await Project.fromCwd({ requireProjectType: false });
const context = new FormatContext(options);
const planner = new FormatPlanner();
const formatConfig = await getFormatConfig();
const formatters = Object.entries(formatterMap).map(
([, FormatterClass]) => new FormatterClass(context, project),
);
const activeFormatters = formatters.filter((formatter) => {
if (formatConfig.modules.only.length > 0) {
return formatConfig.modules.only.includes(formatter.name);
}
if (formatConfig.modules.skip.includes(formatter.name)) {
return false;
}
return true;
});
return {
context,
planner,
formatConfig,
activeFormatters,
};
};
const buildFormatPlan = async (options: {
fromPlan?: string;
interactive: boolean;
jsonOutput: boolean;
}) => {
const { context, planner, formatConfig, activeFormatters } =
await createActiveFormatters({
interactive: options.interactive,
jsonOutput: options.jsonOutput,
});
const plan = options.fromPlan
? JSON.parse(
(await plugins.smartfs
.file(options.fromPlan)
.encoding("utf8")
.read()) as string,
)
: await planner.planFormat(activeFormatters);
return {
context,
planner,
formatConfig,
activeFormatters,
plan,
};
};
const serializePlan = (plan: any) => {
return {
summary: plan.summary,
warnings: plan.warnings,
changes: plan.changes.map((change: any) => ({
type: change.type,
path: change.path,
module: change.module,
description: change.description,
})),
};
};
export let run = async ( export let run = async (
options: { options: {
@@ -45,59 +179,61 @@ export let run = async (
interactive?: boolean; interactive?: boolean;
verbose?: boolean; verbose?: boolean;
diff?: boolean; diff?: boolean;
[key: string]: any;
} = {}, } = {},
): Promise<any> => { ): Promise<any> => {
const mode = await getCliMode(options as any);
const subcommand = (options as any)?._?.[1];
if (mode.help || subcommand === "help") {
showHelp(mode);
return;
}
if (options.verbose) { if (options.verbose) {
setVerboseMode(true); setVerboseMode(true);
} }
const shouldWrite = options.write ?? (options.dryRun === false); const shouldWrite = options.write ?? options.dryRun === false;
const treatAsPlan = subcommand === "plan";
const project = await Project.fromCwd({ requireProjectType: false }); if (mode.json && shouldWrite) {
const context = new FormatContext(); printJson({
const planner = new FormatPlanner(); ok: false,
error:
const smartconfigInstance = new plugins.smartconfig.Smartconfig(); "JSON output is only supported for read-only format planning. Use `gitzone format plan --json` or omit `--json` when applying changes.",
const formatConfig = smartconfigInstance.dataFor<any>('@git.zone/cli.format', {
interactive: true,
showDiffs: false,
autoApprove: false,
modules: {
skip: [],
only: [],
},
}); });
return;
}
const interactive = options.interactive ?? formatConfig.interactive; // Migrate config file before anything reads it
await migrateConfigFile(shouldWrite);
const formatConfig = await getFormatConfig();
const interactive =
options.interactive ?? (mode.interactive && formatConfig.interactive);
const autoApprove = options.yes ?? formatConfig.autoApprove; const autoApprove = options.yes ?? formatConfig.autoApprove;
try { try {
// Initialize formatters in execution order const planBuilder = async () => {
const formatters = Object.entries(formatterMap).map( return await buildFormatPlan({
([, FormatterClass]) => new FormatterClass(context, project), fromPlan: options.fromPlan,
); interactive,
jsonOutput: mode.json,
// Filter formatters based on configuration
const activeFormatters = formatters.filter((formatter) => {
if (formatConfig.modules.only.length > 0) {
return formatConfig.modules.only.includes(formatter.name);
}
if (formatConfig.modules.skip.includes(formatter.name)) {
return false;
}
return true;
}); });
};
// Plan phase if (!mode.json) {
logger.log('info', 'Analyzing project for format operations...'); logger.log("info", "Analyzing project for format operations...");
let plan = options.fromPlan }
? JSON.parse( const { context, planner, activeFormatters, plan } = mode.json
(await plugins.smartfs ? await runWithSuppressedOutput(planBuilder)
.file(options.fromPlan) : await planBuilder();
.encoding('utf8')
.read()) as string, if (mode.json) {
) printJson(serializePlan(plan));
: await planner.planFormat(activeFormatters); return;
}
// Display plan // Display plan
await planner.displayPlan(plan, options.detailed); await planner.displayPlan(plan, options.detailed);
@@ -106,34 +242,35 @@ export let run = async (
if (options.savePlan) { if (options.savePlan) {
await plugins.smartfs await plugins.smartfs
.file(options.savePlan) .file(options.savePlan)
.encoding('utf8') .encoding("utf8")
.write(JSON.stringify(plan, null, 2)); .write(JSON.stringify(plan, null, 2));
logger.log('info', `Plan saved to ${options.savePlan}`); logger.log("info", `Plan saved to ${options.savePlan}`);
} }
if (options.planOnly) { if (options.planOnly || treatAsPlan) {
return; return;
} }
// Show diffs if explicitly requested or before interactive write confirmation // Show diffs if explicitly requested or before interactive write confirmation
const showDiffs = options.diff || (shouldWrite && interactive && !autoApprove); const showDiffs =
options.diff || (shouldWrite && interactive && !autoApprove);
if (showDiffs) { if (showDiffs) {
logger.log('info', 'Showing file diffs:'); logger.log("info", "Showing file diffs:");
console.log(''); console.log("");
for (const formatter of activeFormatters) { for (const formatter of activeFormatters) {
const checkResult = await formatter.check(); const checkResult = await formatter.check();
if (checkResult.hasDiff) { if (checkResult.hasDiff) {
logger.log('info', `[${formatter.name}]`); logger.log("info", `[${formatter.name}]`);
formatter.displayAllDiffs(checkResult); formatter.displayAllDiffs(checkResult);
console.log(''); console.log("");
} }
} }
} }
// Dry-run mode (default behavior) // Dry-run mode (default behavior)
if (!shouldWrite) { if (!shouldWrite) {
logger.log('info', 'Dry-run mode - use --write (-w) to apply changes'); logger.log("info", "Dry-run mode - use --write (-w) to apply changes");
return; return;
} }
@@ -141,25 +278,25 @@ export let run = async (
if (interactive && !autoApprove) { if (interactive && !autoApprove) {
const interactInstance = new plugins.smartinteract.SmartInteract(); const interactInstance = new plugins.smartinteract.SmartInteract();
const response = await interactInstance.askQuestion({ const response = await interactInstance.askQuestion({
type: 'confirm', type: "confirm",
name: 'proceed', name: "proceed",
message: 'Proceed with formatting?', message: "Proceed with formatting?",
default: true, default: true,
}); });
if (!(response as any).value) { if (!(response as any).value) {
logger.log('info', 'Format operation cancelled by user'); logger.log("info", "Format operation cancelled by user");
return; return;
} }
} }
// Execute phase // Execute phase
logger.log('info', 'Executing format operations...'); logger.log("info", "Executing format operations...");
await planner.executePlan(plan, activeFormatters, context); await planner.executePlan(plan, activeFormatters, context);
context.getFormatStats().finish(); context.getFormatStats().finish();
const showStats = smartconfigInstance.dataFor('gitzone.format.showStats', true); const showStats = formatConfig.showStats ?? true;
if (showStats) { if (showStats) {
context.getFormatStats().displayStats(); context.getFormatStats().displayStats();
} }
@@ -169,14 +306,15 @@ export let run = async (
await context.getFormatStats().saveReport(statsPath); await context.getFormatStats().saveReport(statsPath);
} }
logger.log('success', 'Format operations completed successfully!'); logger.log("success", "Format operations completed successfully!");
} catch (error) { } catch (error) {
logger.log('error', `Format operation failed: ${error.message}`); const errorMessage = error instanceof Error ? error.message : String(error);
logger.log("error", `Format operation failed: ${errorMessage}`);
throw error; throw error;
} }
}; };
import type { ICheckResult } from './interfaces.format.js'; import type { ICheckResult } from "./interfaces.format.js";
export type { ICheckResult }; export type { ICheckResult };
/** /**
@@ -188,11 +326,12 @@ export const runFormatter = async (
silent?: boolean; silent?: boolean;
checkOnly?: boolean; checkOnly?: boolean;
showDiff?: boolean; showDiff?: boolean;
} = {} } = {},
): Promise<ICheckResult | void> => { ): Promise<ICheckResult | void> => {
const requireProjectType = !formattersNotRequiringProjectType.includes(formatterName); const requireProjectType =
!formattersNotRequiringProjectType.includes(formatterName);
const project = await Project.fromCwd({ requireProjectType }); const project = await Project.fromCwd({ requireProjectType });
const context = new FormatContext(); const context = new FormatContext({ interactive: true, jsonOutput: false });
const FormatterClass = formatterMap[formatterName]; const FormatterClass = formatterMap[formatterName];
if (!FormatterClass) { if (!FormatterClass) {
@@ -216,6 +355,80 @@ export const runFormatter = async (
} }
if (!options.silent) { if (!options.silent) {
logger.log('success', `Formatter '${formatterName}' completed`); logger.log("success", `Formatter '${formatterName}' completed`);
} }
}; };
export function showHelp(mode?: ICliMode): void {
if (mode?.json) {
printJson({
command: "format",
usage: "gitzone format [plan] [options]",
description:
"Plans formatting changes by default and applies them only with --write.",
flags: [
{ flag: "--write, -w", description: "Apply planned changes" },
{
flag: "--yes",
description: "Skip the interactive confirmation before writing",
},
{
flag: "--plan-only",
description: "Show the plan without applying changes",
},
{
flag: "--save-plan <file>",
description: "Write the format plan to a file",
},
{
flag: "--from-plan <file>",
description: "Load a previously saved plan",
},
{
flag: "--detailed",
description: "Show detailed diffs and save stats",
},
{ flag: "--verbose", description: "Enable verbose logging" },
{
flag: "--diff",
description: "Show per-file diffs before applying changes",
},
{ flag: "--json", description: "Emit a read-only format plan as JSON" },
],
examples: [
"gitzone format",
"gitzone format plan --json",
"gitzone format --write --yes",
],
});
return;
}
console.log("");
console.log("Usage: gitzone format [plan] [options]");
console.log("");
console.log(
"Plans formatting changes by default and applies them only with --write.",
);
console.log("");
console.log("Flags:");
console.log(" --write, -w Apply planned changes");
console.log(
" --yes Skip the interactive confirmation before writing",
);
console.log(" --plan-only Show the plan without applying changes");
console.log(" --save-plan <file> Write the format plan to a file");
console.log(" --from-plan <file> Load a previously saved plan");
console.log(" --detailed Show detailed diffs and save stats");
console.log(" --verbose Enable verbose logging");
console.log(
" --diff Show per-file diffs before applying changes",
);
console.log(" --json Emit a read-only format plan as JSON");
console.log("");
console.log("Examples:");
console.log(" gitzone format");
console.log(" gitzone format plan --json");
console.log(" gitzone format --write --yes");
console.log("");
}
-2
View File
@@ -5,7 +5,6 @@ import * as smartfile from '@push.rocks/smartfile';
import * as smartinteract from '@push.rocks/smartinteract'; import * as smartinteract from '@push.rocks/smartinteract';
import * as smartlegal from '@push.rocks/smartlegal'; import * as smartlegal from '@push.rocks/smartlegal';
import * as smartobject from '@push.rocks/smartobject'; import * as smartobject from '@push.rocks/smartobject';
import * as smartnpm from '@push.rocks/smartnpm';
import * as smartconfig from '@push.rocks/smartconfig'; import * as smartconfig from '@push.rocks/smartconfig';
import * as smartdiff from '@push.rocks/smartdiff'; import * as smartdiff from '@push.rocks/smartdiff';
import * as smartscaf from '@push.rocks/smartscaf'; import * as smartscaf from '@push.rocks/smartscaf';
@@ -16,7 +15,6 @@ export {
smartinteract, smartinteract,
smartlegal, smartlegal,
smartobject, smartobject,
smartnpm,
smartconfig, smartconfig,
smartdiff, smartdiff,
smartscaf, smartscaf,
+393
View File
@@ -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("");
}
+5
View File
@@ -0,0 +1,5 @@
export * from "../plugins.js";
import * as commitinfo from "@push.rocks/commitinfo";
export { commitinfo };
+535 -168
View File
@@ -1,12 +1,26 @@
import * as plugins from './mod.plugins.js'; import * as plugins from "./mod.plugins.js";
import * as helpers from './helpers.js'; import * as helpers from "./helpers.js";
import { ServiceManager } from './classes.servicemanager.js'; import { ServiceManager } from "./classes.servicemanager.js";
import { GlobalRegistry } from './classes.globalregistry.js'; import { GlobalRegistry } from "./classes.globalregistry.js";
import { logger } from '../gitzone.logging.js'; import { logger } from "../gitzone.logging.js";
import type { ICliMode } from "../helpers.climode.js";
import { getCliMode, printJson } from "../helpers.climode.js";
import {
getCliConfigValueFromData,
readSmartconfigFile,
setCliConfigValueInData,
writeSmartconfigFile,
} from "../helpers.smartconfig.js";
export const run = async (argvArg: any) => { export const run = async (argvArg: any) => {
const mode = await getCliMode(argvArg);
const isGlobal = argvArg.g || argvArg.global; const isGlobal = argvArg.g || argvArg.global;
const command = argvArg._[1] || 'help'; const command = argvArg._[1] || "help";
if (mode.help || command === "help") {
showHelp(mode);
return;
}
// Handle global commands first // Handle global commands first
if (isGlobal) { if (isGlobal) {
@@ -14,264 +28,597 @@ export const run = async (argvArg: any) => {
return; return;
} }
// Local project commands const service = argvArg._[2] || "all";
const serviceManager = new ServiceManager();
await serviceManager.init();
const service = argvArg._[2] || 'all';
switch (command) { switch (command) {
case 'start': case "config":
await handleStart(serviceManager, service); if (service === "services" || argvArg._[2] === "services") {
break; const serviceManager = new ServiceManager();
await serviceManager.init();
case 'stop':
await handleStop(serviceManager, service);
break;
case 'restart':
await handleRestart(serviceManager, service);
break;
case 'status':
await serviceManager.showStatus();
break;
case 'config':
if (service === 'services' || argvArg._[2] === 'services') {
await handleConfigureServices(serviceManager); await handleConfigureServices(serviceManager);
} else { } else {
await serviceManager.showConfig(); await handleShowConfig(mode);
} }
break; break;
case 'compass': case "set":
await handleSetServices(argvArg._[2], mode);
break;
case "enable":
await handleEnableServices(argvArg._.slice(2), mode);
break;
case "disable":
await handleDisableServices(argvArg._.slice(2), mode);
break;
case "start":
case "stop":
case "restart":
case "status":
case "compass":
case "logs":
case "remove":
case "clean":
case "reconfigure": {
const serviceManager = new ServiceManager();
await serviceManager.init();
switch (command) {
case "start":
await handleStart(serviceManager, service);
break;
case "stop":
await handleStop(serviceManager, service);
break;
case "restart":
await handleRestart(serviceManager, service);
break;
case "status":
await serviceManager.showStatus();
break;
case "compass":
await serviceManager.showCompassConnection(); await serviceManager.showCompassConnection();
break; break;
case 'logs': case "logs": {
const lines = parseInt(argvArg._[3]) || 20; const lines = parseInt(argvArg._[3]) || 20;
await serviceManager.showLogs(service, lines); await serviceManager.showLogs(service, lines);
break; break;
}
case 'remove': case "remove":
await handleRemove(serviceManager); await handleRemove(serviceManager);
break; break;
case 'clean': case "clean":
await handleClean(serviceManager); await handleClean(serviceManager);
break; break;
case 'reconfigure': case "reconfigure":
await serviceManager.reconfigure(); await serviceManager.reconfigure();
break; break;
}
case 'help': break;
}
default: default:
showHelp(); showHelp(mode);
break; break;
} }
}; };
const allowedServices = ["mongodb", "minio", "elasticsearch"];
const normalizeServiceName = (service: string): string => {
switch (service) {
case "mongo":
case "mongodb":
return "mongodb";
case "minio":
case "s3":
return "minio";
case "elastic":
case "elasticsearch":
case "es":
return "elasticsearch";
default:
return service;
}
};
async function readServicesConfig(): Promise<{
enabledServices: string[];
environment: Record<string, any> | null;
}> {
const smartconfigData = await readSmartconfigFile();
const enabledServices = getCliConfigValueFromData(
smartconfigData,
"services",
);
let environment: Record<string, any> | null = null;
const envPath = plugins.path.join(process.cwd(), ".nogit", "env.json");
if (await plugins.smartfs.file(envPath).exists()) {
const envContent = (await plugins.smartfs
.file(envPath)
.encoding("utf8")
.read()) as string;
environment = JSON.parse(envContent);
}
return {
enabledServices: Array.isArray(enabledServices) ? enabledServices : [],
environment,
};
}
async function updateEnabledServices(services: string[]): Promise<void> {
const smartconfigData = await readSmartconfigFile();
setCliConfigValueInData(smartconfigData, "services", services);
await writeSmartconfigFile(smartconfigData);
}
async function handleShowConfig(mode: ICliMode) {
const configData = await readServicesConfig();
if (mode.json) {
printJson(configData);
return;
}
helpers.printHeader("Current Services Configuration");
logger.log(
"info",
`Enabled Services: ${configData.enabledServices.length > 0 ? configData.enabledServices.join(", ") : "none configured"}`,
);
console.log();
if (!configData.environment) {
logger.log(
"note",
"No .nogit/env.json found yet. Start a service once to create runtime defaults.",
);
return;
}
const env = configData.environment;
logger.log("note", "MongoDB:");
logger.log("info", ` Host: ${env.MONGODB_HOST}:${env.MONGODB_PORT}`);
logger.log("info", ` Database: ${env.MONGODB_NAME}`);
logger.log("info", ` User: ${env.MONGODB_USER}`);
logger.log("info", ` Container: ${env.PROJECT_NAME}-mongodb`);
logger.log(
"info",
` Data: ${plugins.path.join(process.cwd(), ".nogit", "mongodata")}`,
);
logger.log("info", ` Connection: ${env.MONGODB_URL}`);
console.log();
logger.log("note", "S3/MinIO:");
logger.log("info", ` Host: ${env.S3_HOST}`);
logger.log("info", ` API Port: ${env.S3_PORT}`);
logger.log("info", ` Console Port: ${env.S3_CONSOLE_PORT}`);
logger.log("info", ` Bucket: ${env.S3_BUCKET}`);
logger.log("info", ` Container: ${env.PROJECT_NAME}-minio`);
logger.log(
"info",
` Data: ${plugins.path.join(process.cwd(), ".nogit", "miniodata")}`,
);
logger.log("info", ` Endpoint: ${env.S3_ENDPOINT}`);
console.log();
logger.log("note", "Elasticsearch:");
logger.log(
"info",
` Host: ${env.ELASTICSEARCH_HOST}:${env.ELASTICSEARCH_PORT}`,
);
logger.log("info", ` User: ${env.ELASTICSEARCH_USER}`);
logger.log("info", ` Container: ${env.PROJECT_NAME}-elasticsearch`);
logger.log(
"info",
` Data: ${plugins.path.join(process.cwd(), ".nogit", "esdata")}`,
);
logger.log("info", ` Connection: ${env.ELASTICSEARCH_URL}`);
}
async function handleSetServices(rawValue: string | undefined, mode: ICliMode) {
if (!rawValue) {
throw new Error("Specify a comma-separated list of services");
}
const requestedServices = rawValue
.split(",")
.map((service) => normalizeServiceName(service.trim()))
.filter(Boolean);
validateRequestedServices(requestedServices);
await updateEnabledServices(requestedServices);
if (mode.json) {
printJson({ ok: true, action: "set", enabledServices: requestedServices });
return;
}
logger.log("ok", `Enabled services set to: ${requestedServices.join(", ")}`);
}
async function handleEnableServices(
requestedServices: string[],
mode: ICliMode,
) {
const normalizedServices = requestedServices.map((service) =>
normalizeServiceName(service),
);
validateRequestedServices(normalizedServices);
const configData = await readServicesConfig();
const nextServices = Array.from(
new Set([...configData.enabledServices, ...normalizedServices]),
);
await updateEnabledServices(nextServices);
if (mode.json) {
printJson({ ok: true, action: "enable", enabledServices: nextServices });
return;
}
logger.log("ok", `Enabled services: ${nextServices.join(", ")}`);
}
async function handleDisableServices(
requestedServices: string[],
mode: ICliMode,
) {
const normalizedServices = requestedServices.map((service) =>
normalizeServiceName(service),
);
validateRequestedServices(normalizedServices);
const configData = await readServicesConfig();
const nextServices = configData.enabledServices.filter(
(service) => !normalizedServices.includes(service),
);
await updateEnabledServices(nextServices);
if (mode.json) {
printJson({ ok: true, action: "disable", enabledServices: nextServices });
return;
}
logger.log("ok", `Enabled services: ${nextServices.join(", ")}`);
}
function validateRequestedServices(services: string[]): void {
if (services.length === 0) {
throw new Error("Specify at least one service");
}
const invalidServices = services.filter(
(service) => !allowedServices.includes(service),
);
if (invalidServices.length > 0) {
throw new Error(`Unknown service(s): ${invalidServices.join(", ")}`);
}
}
async function handleStart(serviceManager: ServiceManager, service: string) { async function handleStart(serviceManager: ServiceManager, service: string) {
helpers.printHeader('Starting Services'); helpers.printHeader("Starting Services");
switch (service) { switch (service) {
case 'mongo': case "mongo":
case 'mongodb': case "mongodb":
await serviceManager.startMongoDB(); await serviceManager.startMongoDB();
break; break;
case 'minio': case "minio":
case 's3': case "s3":
await serviceManager.startMinIO(); await serviceManager.startMinIO();
break; break;
case 'elasticsearch': case "elasticsearch":
case 'es': case "es":
await serviceManager.startElasticsearch(); await serviceManager.startElasticsearch();
break; break;
case 'all': case "all":
case '': case "":
await serviceManager.startAll(); await serviceManager.startAll();
break; break;
default: default:
logger.log('error', `Unknown service: ${service}`); logger.log("error", `Unknown service: ${service}`);
logger.log('note', 'Use: mongo, s3, elasticsearch, or all'); logger.log("note", "Use: mongo, s3, elasticsearch, or all");
break; break;
} }
} }
async function handleStop(serviceManager: ServiceManager, service: string) { async function handleStop(serviceManager: ServiceManager, service: string) {
helpers.printHeader('Stopping Services'); helpers.printHeader("Stopping Services");
switch (service) { switch (service) {
case 'mongo': case "mongo":
case 'mongodb': case "mongodb":
await serviceManager.stopMongoDB(); await serviceManager.stopMongoDB();
break; break;
case 'minio': case "minio":
case 's3': case "s3":
await serviceManager.stopMinIO(); await serviceManager.stopMinIO();
break; break;
case 'elasticsearch': case "elasticsearch":
case 'es': case "es":
await serviceManager.stopElasticsearch(); await serviceManager.stopElasticsearch();
break; break;
case 'all': case "all":
case '': case "":
await serviceManager.stopAll(); await serviceManager.stopAll();
break; break;
default: default:
logger.log('error', `Unknown service: ${service}`); logger.log("error", `Unknown service: ${service}`);
logger.log('note', 'Use: mongo, s3, elasticsearch, or all'); logger.log("note", "Use: mongo, s3, elasticsearch, or all");
break; break;
} }
} }
async function handleRestart(serviceManager: ServiceManager, service: string) { async function handleRestart(serviceManager: ServiceManager, service: string) {
helpers.printHeader('Restarting Services'); helpers.printHeader("Restarting Services");
switch (service) { switch (service) {
case 'mongo': case "mongo":
case 'mongodb': case "mongodb":
await serviceManager.stopMongoDB(); await serviceManager.stopMongoDB();
await plugins.smartdelay.delayFor(2000); await plugins.smartdelay.delayFor(2000);
await serviceManager.startMongoDB(); await serviceManager.startMongoDB();
break; break;
case 'minio': case "minio":
case 's3': case "s3":
await serviceManager.stopMinIO(); await serviceManager.stopMinIO();
await plugins.smartdelay.delayFor(2000); await plugins.smartdelay.delayFor(2000);
await serviceManager.startMinIO(); await serviceManager.startMinIO();
break; break;
case 'elasticsearch': case "elasticsearch":
case 'es': case "es":
await serviceManager.stopElasticsearch(); await serviceManager.stopElasticsearch();
await plugins.smartdelay.delayFor(2000); await plugins.smartdelay.delayFor(2000);
await serviceManager.startElasticsearch(); await serviceManager.startElasticsearch();
break; break;
case 'all': case "all":
case '': case "":
await serviceManager.stopAll(); await serviceManager.stopAll();
await plugins.smartdelay.delayFor(2000); await plugins.smartdelay.delayFor(2000);
await serviceManager.startAll(); await serviceManager.startAll();
break; break;
default: default:
logger.log('error', `Unknown service: ${service}`); logger.log("error", `Unknown service: ${service}`);
break; break;
} }
} }
async function handleRemove(serviceManager: ServiceManager) { async function handleRemove(serviceManager: ServiceManager) {
helpers.printHeader('Removing Containers'); helpers.printHeader("Removing Containers");
logger.log('note', '⚠️ This will remove containers but preserve data'); logger.log("note", "⚠️ This will remove containers but preserve data");
const shouldContinue = await plugins.smartinteract.SmartInteract.getCliConfirmation('Continue?', false); const shouldContinue =
await plugins.smartinteract.SmartInteract.getCliConfirmation(
"Continue?",
false,
);
if (shouldContinue) { if (shouldContinue) {
await serviceManager.removeContainers(); await serviceManager.removeContainers();
} else { } else {
logger.log('note', 'Cancelled'); logger.log("note", "Cancelled");
} }
} }
async function handleClean(serviceManager: ServiceManager) { async function handleClean(serviceManager: ServiceManager) {
helpers.printHeader('Clean All'); helpers.printHeader("Clean All");
logger.log('error', '⚠️ WARNING: This will remove all containers and data!'); logger.log("error", "⚠️ WARNING: This will remove all containers and data!");
logger.log('error', 'This action cannot be undone!'); logger.log("error", "This action cannot be undone!");
const smartinteraction = new plugins.smartinteract.SmartInteract(); const smartinteraction = new plugins.smartinteract.SmartInteract();
const confirmAnswer = await smartinteraction.askQuestion({ const confirmAnswer = await smartinteraction.askQuestion({
name: 'confirm', name: "confirm",
type: 'input', type: "input",
message: 'Type "yes" to confirm:', message: 'Type "yes" to confirm:',
default: 'no' default: "no",
}); });
if (confirmAnswer.value === 'yes') { if (confirmAnswer.value === "yes") {
await serviceManager.removeContainers(); await serviceManager.removeContainers();
console.log(); console.log();
await serviceManager.cleanData(); await serviceManager.cleanData();
logger.log('ok', 'All cleaned ✓'); logger.log("ok", "All cleaned ✓");
} else { } else {
logger.log('note', 'Cancelled'); logger.log("note", "Cancelled");
} }
} }
async function handleConfigureServices(serviceManager: ServiceManager) { async function handleConfigureServices(serviceManager: ServiceManager) {
helpers.printHeader('Configure Services'); helpers.printHeader("Configure Services");
await serviceManager.configureServices(); await serviceManager.configureServices();
} }
function showHelp() { export function showHelp(mode?: ICliMode) {
helpers.printHeader('GitZone Services Manager'); if (mode?.json) {
printJson({
command: "services",
usage: "gitzone services <command> [options]",
commands: [
{
name: "config",
description:
"Show configured services and any existing runtime env.json data",
},
{
name: "set <csv>",
description: "Set the enabled service list without prompts",
},
{
name: "enable <service...>",
description: "Enable one or more services without prompts",
},
{
name: "disable <service...>",
description: "Disable one or more services without prompts",
},
{ name: "start [service]", description: "Start services" },
{ name: "stop [service]", description: "Stop services" },
{ name: "status", description: "Show service status" },
],
examples: [
"gitzone services config --json",
"gitzone services set mongodb,minio",
"gitzone services enable elasticsearch",
],
});
return;
}
logger.log('ok', 'Usage: gitzone services [command] [options]'); helpers.printHeader("GitZone Services Manager");
logger.log("ok", "Usage: gitzone services [command] [options]");
console.log(); console.log();
logger.log('note', 'Commands:'); logger.log("note", "Commands:");
logger.log('info', ' start [service] Start services (mongo|s3|elasticsearch|all)'); logger.log(
logger.log('info', ' stop [service] Stop services (mongo|s3|elasticsearch|all)'); "info",
logger.log('info', ' restart [service] Restart services (mongo|s3|elasticsearch|all)'); " start [service] Start services (mongo|s3|elasticsearch|all)",
logger.log('info', ' status Show service status'); );
logger.log('info', ' config Show current configuration'); logger.log(
logger.log('info', ' config services Configure which services are enabled'); "info",
logger.log('info', ' compass Show MongoDB Compass connection string'); " stop [service] Stop services (mongo|s3|elasticsearch|all)",
logger.log('info', ' logs [service] Show logs (mongo|s3|elasticsearch|all) [lines]'); );
logger.log('info', ' reconfigure Reassign ports and restart services'); logger.log(
logger.log('info', ' remove Remove all containers'); "info",
logger.log('info', ' clean Remove all containers and data ⚠️'); " restart [service] Restart services (mongo|s3|elasticsearch|all)",
logger.log('info', ' help Show this help message'); );
logger.log("info", " status Show service status");
logger.log("info", " config Show current configuration");
logger.log(
"info",
" config services Configure which services are enabled",
);
logger.log(
"info",
" set <csv> Set enabled services without prompts",
);
logger.log("info", " enable <svc...> Enable one or more services");
logger.log("info", " disable <svc...> Disable one or more services");
logger.log(
"info",
" compass Show MongoDB Compass connection string",
);
logger.log(
"info",
" logs [service] Show logs (mongo|s3|elasticsearch|all) [lines]",
);
logger.log("info", " reconfigure Reassign ports and restart services");
logger.log("info", " remove Remove all containers");
logger.log("info", " clean Remove all containers and data ⚠️");
logger.log("info", " help Show this help message");
console.log(); console.log();
logger.log('note', 'Available Services:'); logger.log("note", "Available Services:");
logger.log('info', ' • MongoDB (mongo) - Document database'); logger.log("info", " • MongoDB (mongo) - Document database");
logger.log('info', ' • MinIO (s3) - S3-compatible object storage'); logger.log("info", " • MinIO (s3) - S3-compatible object storage");
logger.log('info', ' • Elasticsearch (elasticsearch) - Search and analytics engine'); logger.log(
"info",
" • Elasticsearch (elasticsearch) - Search and analytics engine",
);
console.log(); console.log();
logger.log('note', 'Features:'); logger.log("note", "Features:");
logger.log('info', ' • Auto-creates .nogit/env.json with smart defaults'); logger.log("info", " • Auto-creates .nogit/env.json with smart defaults");
logger.log('info', ' • Random ports (20000-30000) for MongoDB/MinIO to avoid conflicts'); logger.log(
logger.log('info', ' • Elasticsearch uses standard port 9200'); "info",
logger.log('info', ' • Project-specific containers for multi-project support'); " • Random ports (20000-30000) for MongoDB/MinIO to avoid conflicts",
logger.log('info', ' • Preserves custom configuration values'); );
logger.log('info', 'MongoDB Compass connection support'); logger.log("info", "Elasticsearch uses standard port 9200");
logger.log(
"info",
" • Project-specific containers for multi-project support",
);
logger.log("info", " • Preserves custom configuration values");
logger.log("info", " • MongoDB Compass connection support");
console.log(); console.log();
logger.log('note', 'Examples:'); logger.log("note", "Examples:");
logger.log('info', ' gitzone services start # Start all services'); logger.log(
logger.log('info', ' gitzone services start mongo # Start only MongoDB'); "info",
logger.log('info', ' gitzone services start elasticsearch # Start only Elasticsearch'); " gitzone services start # Start all services",
logger.log('info', ' gitzone services stop # Stop all services'); );
logger.log('info', ' gitzone services status # Check service status'); logger.log(
logger.log('info', ' gitzone services config # Show configuration'); "info",
logger.log('info', ' gitzone services compass # Get MongoDB Compass connection'); " gitzone services start mongo # Start only MongoDB",
logger.log('info', ' gitzone services logs elasticsearch # Show Elasticsearch logs'); );
logger.log(
"info",
" gitzone services start elasticsearch # Start only Elasticsearch",
);
logger.log(
"info",
" gitzone services stop # Stop all services",
);
logger.log(
"info",
" gitzone services status # Check service status",
);
logger.log(
"info",
" gitzone services config # Show configuration",
);
logger.log(
"info",
" gitzone services config --json # Show configuration as JSON",
);
logger.log(
"info",
" gitzone services set mongodb,minio # Configure services without prompts",
);
logger.log(
"info",
" gitzone services compass # Get MongoDB Compass connection",
);
logger.log(
"info",
" gitzone services logs elasticsearch # Show Elasticsearch logs",
);
console.log(); console.log();
logger.log('note', 'Global Commands (-g/--global):'); logger.log("note", "Global Commands (-g/--global):");
logger.log('info', ' list -g List all registered projects'); logger.log("info", " list -g List all registered projects");
logger.log('info', ' status -g Show status across all projects'); logger.log("info", " status -g Show status across all projects");
logger.log('info', ' stop -g Stop all containers across all projects'); logger.log(
logger.log('info', ' cleanup -g Remove stale registry entries'); "info",
" stop -g Stop all containers across all projects",
);
logger.log("info", " cleanup -g Remove stale registry entries");
console.log(); console.log();
logger.log('note', 'Global Examples:'); logger.log("note", "Global Examples:");
logger.log('info', ' gitzone services list -g # List all registered projects'); logger.log(
logger.log('info', ' gitzone services status -g # Show global container status'); "info",
logger.log('info', ' gitzone services stop -g # Stop all (prompts for confirmation)'); " gitzone services list -g # List all registered projects",
);
logger.log(
"info",
" gitzone services status -g # Show global container status",
);
logger.log(
"info",
" gitzone services stop -g # Stop all (prompts for confirmation)",
);
} }
// ==================== Global Command Handlers ==================== // ==================== Global Command Handlers ====================
@@ -280,23 +627,23 @@ async function handleGlobalCommand(command: string) {
const globalRegistry = GlobalRegistry.getInstance(); const globalRegistry = GlobalRegistry.getInstance();
switch (command) { switch (command) {
case 'list': case "list":
await handleGlobalList(globalRegistry); await handleGlobalList(globalRegistry);
break; break;
case 'status': case "status":
await handleGlobalStatus(globalRegistry); await handleGlobalStatus(globalRegistry);
break; break;
case 'stop': case "stop":
await handleGlobalStop(globalRegistry); await handleGlobalStop(globalRegistry);
break; break;
case 'cleanup': case "cleanup":
await handleGlobalCleanup(globalRegistry); await handleGlobalCleanup(globalRegistry);
break; break;
case 'help': case "help":
default: default:
showHelp(); showHelp();
break; break;
@@ -304,13 +651,13 @@ async function handleGlobalCommand(command: string) {
} }
async function handleGlobalList(globalRegistry: GlobalRegistry) { async function handleGlobalList(globalRegistry: GlobalRegistry) {
helpers.printHeader('Registered Projects (Global)'); helpers.printHeader("Registered Projects (Global)");
const projects = await globalRegistry.getAllProjects(); const projects = await globalRegistry.getAllProjects();
const projectPaths = Object.keys(projects); const projectPaths = Object.keys(projects);
if (projectPaths.length === 0) { if (projectPaths.length === 0) {
logger.log('note', 'No projects registered'); logger.log("note", "No projects registered");
return; return;
} }
@@ -319,20 +666,20 @@ async function handleGlobalList(globalRegistry: GlobalRegistry) {
const lastActive = new Date(project.lastActive).toLocaleString(); const lastActive = new Date(project.lastActive).toLocaleString();
console.log(); console.log();
logger.log('ok', `📁 ${project.projectName}`); logger.log("ok", `📁 ${project.projectName}`);
logger.log('info', ` Path: ${project.projectPath}`); logger.log("info", ` Path: ${project.projectPath}`);
logger.log('info', ` Services: ${project.enabledServices.join(', ')}`); logger.log("info", ` Services: ${project.enabledServices.join(", ")}`);
logger.log('info', ` Last Active: ${lastActive}`); logger.log("info", ` Last Active: ${lastActive}`);
} }
} }
async function handleGlobalStatus(globalRegistry: GlobalRegistry) { async function handleGlobalStatus(globalRegistry: GlobalRegistry) {
helpers.printHeader('Global Service Status'); helpers.printHeader("Global Service Status");
const statuses = await globalRegistry.getGlobalStatus(); const statuses = await globalRegistry.getGlobalStatus();
if (statuses.length === 0) { if (statuses.length === 0) {
logger.log('note', 'No projects registered'); logger.log("note", "No projects registered");
return; return;
} }
@@ -341,28 +688,39 @@ async function handleGlobalStatus(globalRegistry: GlobalRegistry) {
for (const project of statuses) { for (const project of statuses) {
console.log(); console.log();
logger.log('ok', `📁 ${project.projectName}`); logger.log("ok", `📁 ${project.projectName}`);
logger.log('info', ` Path: ${project.projectPath}`); logger.log("info", ` Path: ${project.projectPath}`);
if (project.containers.length === 0) { if (project.containers.length === 0) {
logger.log('note', ' No containers configured'); logger.log("note", " No containers configured");
continue; continue;
} }
for (const container of project.containers) { for (const container of project.containers) {
totalContainers++; totalContainers++;
const statusIcon = container.status === 'running' ? '🟢' : container.status === 'exited' ? '🟡' : '⚪'; const statusIcon =
if (container.status === 'running') runningCount++; container.status === "running"
logger.log('info', ` ${statusIcon} ${container.name}: ${container.status}`); ? "🟢"
: container.status === "exited"
? "🟡"
: "⚪";
if (container.status === "running") runningCount++;
logger.log(
"info",
` ${statusIcon} ${container.name}: ${container.status}`,
);
} }
} }
console.log(); console.log();
logger.log('note', `Summary: ${runningCount}/${totalContainers} containers running across ${statuses.length} project(s)`); logger.log(
"note",
`Summary: ${runningCount}/${totalContainers} containers running across ${statuses.length} project(s)`,
);
} }
async function handleGlobalStop(globalRegistry: GlobalRegistry) { async function handleGlobalStop(globalRegistry: GlobalRegistry) {
helpers.printHeader('Stop All Containers (Global)'); helpers.printHeader("Stop All Containers (Global)");
const statuses = await globalRegistry.getGlobalStatus(); const statuses = await globalRegistry.getGlobalStatus();
@@ -370,64 +728,73 @@ async function handleGlobalStop(globalRegistry: GlobalRegistry) {
let runningCount = 0; let runningCount = 0;
for (const project of statuses) { for (const project of statuses) {
for (const container of project.containers) { for (const container of project.containers) {
if (container.status === 'running') runningCount++; if (container.status === "running") runningCount++;
} }
} }
if (runningCount === 0) { if (runningCount === 0) {
logger.log('note', 'No running containers found'); logger.log("note", "No running containers found");
return; return;
} }
logger.log('note', `Found ${runningCount} running container(s) across ${statuses.length} project(s)`); logger.log(
"note",
`Found ${runningCount} running container(s) across ${statuses.length} project(s)`,
);
console.log(); console.log();
// Show what will be stopped // Show what will be stopped
for (const project of statuses) { for (const project of statuses) {
const runningContainers = project.containers.filter(c => c.status === 'running'); const runningContainers = project.containers.filter(
(c) => c.status === "running",
);
if (runningContainers.length > 0) { if (runningContainers.length > 0) {
logger.log('info', `${project.projectName}:`); logger.log("info", `${project.projectName}:`);
for (const container of runningContainers) { for (const container of runningContainers) {
logger.log('info', `${container.name}`); logger.log("info", `${container.name}`);
} }
} }
} }
console.log(); console.log();
const shouldContinue = await plugins.smartinteract.SmartInteract.getCliConfirmation( const shouldContinue =
'Stop all containers?', await plugins.smartinteract.SmartInteract.getCliConfirmation(
false "Stop all containers?",
false,
); );
if (!shouldContinue) { if (!shouldContinue) {
logger.log('note', 'Cancelled'); logger.log("note", "Cancelled");
return; return;
} }
logger.log('note', 'Stopping all containers...'); logger.log("note", "Stopping all containers...");
const result = await globalRegistry.stopAll(); const result = await globalRegistry.stopAll();
if (result.stopped.length > 0) { if (result.stopped.length > 0) {
logger.log('ok', `Stopped: ${result.stopped.join(', ')}`); logger.log("ok", `Stopped: ${result.stopped.join(", ")}`);
} }
if (result.failed.length > 0) { if (result.failed.length > 0) {
logger.log('error', `Failed to stop: ${result.failed.join(', ')}`); logger.log("error", `Failed to stop: ${result.failed.join(", ")}`);
} }
} }
async function handleGlobalCleanup(globalRegistry: GlobalRegistry) { async function handleGlobalCleanup(globalRegistry: GlobalRegistry) {
helpers.printHeader('Cleanup Registry (Global)'); helpers.printHeader("Cleanup Registry (Global)");
logger.log('note', 'Checking for stale registry entries...'); logger.log("note", "Checking for stale registry entries...");
const removed = await globalRegistry.cleanup(); const removed = await globalRegistry.cleanup();
if (removed.length === 0) { if (removed.length === 0) {
logger.log('ok', 'No stale entries found'); logger.log("ok", "No stale entries found");
return; return;
} }
logger.log('ok', `Removed ${removed.length} stale entr${removed.length === 1 ? 'y' : 'ies'}:`); logger.log(
"ok",
`Removed ${removed.length} stale entr${removed.length === 1 ? "y" : "ies"}:`,
);
for (const path of removed) { for (const path of removed) {
logger.log('info', `${path}`); logger.log("info", `${path}`);
} }
} }
+217 -58
View File
@@ -1,91 +1,250 @@
/* ----------------------------------------------- /* -----------------------------------------------
* executes as standard task * executes as standard task
* ----------------------------------------------- */ * ----------------------------------------------- */
import * as plugins from './mod.plugins.js'; import * as plugins from "./mod.plugins.js";
import * as paths from '../paths.js'; import * as paths from "../paths.js";
import type { ICliMode } from "../helpers.climode.js";
import { getCliMode, printJson } from "../helpers.climode.js";
import { logger } from '../gitzone.logging.js'; import { logger } from "../gitzone.logging.js";
export let run = async () => { type ICommandHelpSummary = {
console.log(''); name: string;
console.log('╭─────────────────────────────────────────────────────────────╮'); description: string;
console.log('│ gitzone - Development Workflow CLI │'); };
console.log('╰─────────────────────────────────────────────────────────────╯');
console.log(''); const commandSummaries: ICommandHelpSummary[] = [
{
name: "commit",
description:
"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" },
{ name: "tools", description: "Manage the global @git.zone toolchain" },
{ name: "template", description: "Create a project from a template" },
{ name: "open", description: "Open project assets and CI pages" },
{ name: "docker", description: "Run Docker-related maintenance tasks" },
{
name: "deprecate",
description: "Deprecate npm packages across registries",
},
{ name: "meta", description: "Run meta-repository commands" },
{ name: "start", description: "Prepare a project for local work" },
{ name: "helpers", description: "Run helper utilities" },
];
export let run = async (argvArg: any = {}) => {
const mode = await getCliMode(argvArg);
const requestedCommandHelp =
argvArg._?.[0] === "help" ? argvArg._?.[1] : undefined;
if (mode.help || requestedCommandHelp) {
await showHelp(mode, requestedCommandHelp);
return;
}
if (!mode.interactive) {
await showHelp(mode);
return;
}
console.log("");
console.log(
"╭─────────────────────────────────────────────────────────────╮",
);
console.log(
"│ gitzone - Development Workflow CLI │",
);
console.log(
"╰─────────────────────────────────────────────────────────────╯",
);
console.log("");
const interactInstance = new plugins.smartinteract.SmartInteract(); const interactInstance = new plugins.smartinteract.SmartInteract();
const response = await interactInstance.askQuestion({ const response = await interactInstance.askQuestion({
type: 'list', type: "list",
name: 'action', name: "action",
message: 'What would you like to do?', message: "What would you like to do?",
default: 'commit', default: "commit",
choices: [ choices: [
{ name: 'Commit changes (semantic versioning)', value: 'commit' }, { name: "Commit changes", value: "commit" },
{ name: 'Format project files', value: 'format' }, { name: "Release pending changes", value: "release" },
{ name: 'Configure release settings', value: 'config' }, { name: "Format project files", value: "format" },
{ name: 'Create from template', value: 'template' }, { name: "Configure release settings", value: "config" },
{ name: 'Manage dev services (MongoDB, S3)', value: 'services' }, { name: "Create from template", value: "template" },
{ name: 'Open project assets', value: 'open' }, { name: "Manage dev services (MongoDB, S3)", value: "services" },
{ name: 'Show help', value: 'help' }, { name: "Manage global @git.zone tools", value: "tools" },
{ name: "Open project assets", value: "open" },
{ name: "Show help", value: "help" },
], ],
}); });
const action = (response as any).value; const action = (response as any).value;
switch (action) { switch (action) {
case 'commit': { case "commit": {
const modCommit = await import('../mod_commit/index.js'); const modCommit = await import("../mod_commit/index.js");
await modCommit.run({ _: ['commit'] }); await modCommit.run({ _: ["commit"] });
break; break;
} }
case 'format': { case "release": {
const modFormat = await import('../mod_format/index.js'); 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 }); await modFormat.run({ interactive: true });
break; break;
} }
case 'config': { case "config": {
const modConfig = await import('../mod_config/index.js'); const modConfig = await import("../mod_config/index.js");
await modConfig.run({ _: ['config'] }); await modConfig.run({ _: ["config"] });
break; break;
} }
case 'template': { case "template": {
const modTemplate = await import('../mod_template/index.js'); const modTemplate = await import("../mod_template/index.js");
await modTemplate.run({ _: ['template'] }); await modTemplate.run({ _: ["template"] });
break; break;
} }
case 'services': { case "services": {
const modServices = await import('../mod_services/index.js'); const modServices = await import("../mod_services/index.js");
await modServices.run({ _: ['services'] }); await modServices.run({ _: ["services"] });
break; break;
} }
case 'open': { case "tools": {
const modOpen = await import('../mod_open/index.js'); const modTools = await import("../mod_tools/index.js");
await modOpen.run({ _: ['open'] }); await modTools.run({ _: ["tools"] });
break; break;
} }
case 'help': case "open": {
showHelp(); const modOpen = await import("../mod_open/index.js");
await modOpen.run({ _: ["open"] });
break;
}
case "help":
await showHelp(mode);
break; break;
} }
}; };
function showHelp(): void { export async function showHelp(
console.log(''); mode: ICliMode,
console.log('Usage: gitzone <command> [options]'); commandName?: string,
console.log(''); ): Promise<void> {
console.log('Commands:'); if (commandName) {
console.log(' commit Create a semantic commit with versioning'); const handled = await showCommandHelp(commandName, mode);
console.log(' format Format and standardize project files'); if (handled) {
console.log(' config Manage release registry configuration'); return;
console.log(' template Create a new project from template'); }
console.log(' services Manage dev services (MongoDB, S3/MinIO)'); }
console.log(' open Open project assets (GitLab, npm, etc.)');
console.log(' docker Docker-related operations'); if (mode.json) {
console.log(' deprecate Deprecate a package on npm'); printJson({
console.log(' meta Run meta commands'); name: "gitzone",
console.log(' start Start working on a project'); usage: "gitzone <command> [options]",
console.log(' helpers Run helper utilities'); commands: commandSummaries,
console.log(''); globalFlags: [
console.log('Run gitzone <command> --help for more information on a command.'); { flag: "--help, -h", description: "Show help output" },
console.log(''); {
flag: "--json",
description: "Emit machine-readable JSON when supported",
},
{
flag: "--plain",
description: "Use plain text output when supported",
},
{
flag: "--agent",
description: "Prefer non-interactive machine-friendly output",
},
{
flag: "--no-interactive",
description: "Disable prompts and interactive menus",
},
{
flag: "--no-check-updates",
description: "Skip the update check banner",
},
],
});
return;
}
console.log("");
console.log("Usage: gitzone <command> [options]");
console.log("");
console.log("Commands:");
for (const commandSummary of commandSummaries) {
console.log(
` ${commandSummary.name.padEnd(11)} ${commandSummary.description}`,
);
}
console.log("");
console.log("Global flags:");
console.log(" --help, -h Show help output");
console.log(
" --json Emit machine-readable JSON when supported",
);
console.log(" --plain Use plain text output when supported");
console.log(
" --agent Prefer non-interactive machine-friendly output",
);
console.log(" --no-interactive Disable prompts and interactive menus");
console.log(" --no-check-updates Skip the update check banner");
console.log("");
console.log("Examples:");
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(" gitzone tools update");
console.log("");
console.log("Run gitzone <command> --help for command-specific usage.");
console.log("");
}
async function showCommandHelp(
commandName: string,
mode: ICliMode,
): Promise<boolean> {
switch (commandName) {
case "commit": {
const modCommit = await import("../mod_commit/index.js");
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);
return true;
}
case "format": {
const modFormat = await import("../mod_format/index.js");
modFormat.showHelp(mode);
return true;
}
case "services": {
const modServices = await import("../mod_services/index.js");
modServices.showHelp(mode);
return true;
}
case "tools": {
const modTools = await import("../mod_tools/index.js");
modTools.showHelp(mode);
return true;
}
default:
return false;
}
} }
+176
View File
@@ -0,0 +1,176 @@
import * as plugins from "./mod.plugins.js";
export interface IInstalledPackage {
name: string;
version: string;
}
export interface IPackageUpdateInfo {
name: string;
currentVersion: string;
latestVersion: string;
needsUpdate: boolean;
}
export interface IPackageManagerInfo {
available: boolean;
currentVersion: string;
latestVersion: string | null;
needsUpdate: boolean;
}
export class PackageManagerUtil {
private shell = new plugins.smartshell.Smartshell({
executor: "bash",
});
public async detectPnpm(): Promise<boolean> {
try {
const result = await this.shell.execSilent("pnpm --version 2>/dev/null");
return result.exitCode === 0 && Boolean(result.stdout.trim());
} catch {
return false;
}
}
public async getPnpmVersionInfo(): Promise<IPackageManagerInfo> {
const available = await this.detectPnpm();
if (!available) {
return {
available: false,
currentVersion: "unknown",
latestVersion: null,
needsUpdate: false,
};
}
const currentVersion = await this.getCurrentPnpmVersion();
const latestVersion = await this.getLatestVersion("pnpm", ["https://registry.npmjs.org"]);
return {
available: true,
currentVersion,
latestVersion,
needsUpdate: latestVersion ? this.isNewerVersion(currentVersion, latestVersion) : false,
};
}
public async getInstalledPackages(): Promise<IInstalledPackage[]> {
const packages: IInstalledPackage[] = [];
try {
const result = await this.shell.execSilent("pnpm list -g --depth=0 --json 2>/dev/null || true");
const output = result.stdout.trim();
if (!output) {
return packages;
}
const data = JSON.parse(output);
const dataArray = Array.isArray(data) ? data : [data];
for (const item of dataArray) {
const dependencies = item.dependencies || {};
for (const [name, info] of Object.entries(dependencies)) {
if (!name.startsWith("@git.zone/")) {
continue;
}
packages.push({
name,
version: (info as any).version || "unknown",
});
}
}
} catch {
return packages;
}
return packages;
}
public async getLatestVersion(
packageName: string,
registries = ["https://verdaccio.lossless.digital", "https://registry.npmjs.org"],
): Promise<string | null> {
for (const registry of registries) {
const latest = await this.getLatestVersionFromRegistry(registry, packageName);
if (latest) {
return latest;
}
}
return null;
}
public async installLatest(packageName: string): Promise<boolean> {
const packageSpecifier = `${packageName}@latest`;
console.log(` Installing ${packageSpecifier} via pnpm...`);
try {
const result = await this.shell.exec(`pnpm add -g ${shellQuote(packageSpecifier)}`);
return result.exitCode === 0;
} catch {
return false;
}
}
public isNewerVersion(current: string, latest: string): boolean {
const currentParts = normalizeSemver(current);
const latestParts = normalizeSemver(latest);
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
const currentPart = currentParts[i] || 0;
const latestPart = latestParts[i] || 0;
if (latestPart > currentPart) return true;
if (latestPart < currentPart) return false;
}
return false;
}
private async getCurrentPnpmVersion(): Promise<string> {
try {
const result = await this.shell.execSilent("pnpm --version 2>/dev/null");
const versionMatch = result.stdout.trim().match(/(\d+\.\d+\.\d+)/);
return versionMatch?.[1] || "unknown";
} catch {
return "unknown";
}
}
private async getLatestVersionFromRegistry(
registry: string,
packageName: string,
): Promise<string | null> {
const encodedName = packageName.replace("/", "%2f");
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
try {
const response = await fetch(`${registry}/${encodedName}`, {
signal: controller.signal,
headers: {
accept: "application/json",
},
});
if (!response.ok) {
return null;
}
const data = await response.json();
const latest = (data as any)["dist-tags"]?.latest;
return typeof latest === "string" && latest.length > 0 ? latest : null;
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
}
function normalizeSemver(version: string): number[] {
return version
.replace(/^[^\d]*/, "")
.split(".")
.map((part) => parseInt(part, 10) || 0);
}
function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
+359
View File
@@ -0,0 +1,359 @@
import * as plugins from "./mod.plugins.js";
import { commitinfo } from "../00_commitinfo_data.js";
import type { ICliMode } from "../helpers.climode.js";
import { getCliMode, printJson } from "../helpers.climode.js";
import {
PackageManagerUtil,
type IInstalledPackage,
type IPackageUpdateInfo,
} from "./classes.packagemanager.js";
export const GITZONE_PACKAGES = [
"@git.zone/cli",
"@git.zone/tsdoc",
"@git.zone/tsbuild",
"@git.zone/tstest",
"@git.zone/tspublish",
"@git.zone/tsbundle",
"@git.zone/tsdocker",
"@git.zone/tsview",
"@git.zone/tswatch",
"@git.zone/tsrust",
];
export const run = async (argvArg: any = {}): Promise<void> => {
const mode = await getCliMode(argvArg);
const command = argvArg._?.[1] || "help";
if (mode.help || command === "help") {
showHelp(mode);
return;
}
switch (command) {
case "update":
await runUpdate(argvArg, mode);
break;
case "install":
await runInstall(argvArg, mode);
break;
default:
showHelp(mode);
break;
}
};
async function runUpdate(argvArg: any, mode: ICliMode): Promise<void> {
const verbose = Boolean(argvArg.v || argvArg.verbose);
const pmUtil = new PackageManagerUtil();
console.log("Scanning for installed @git.zone packages...\n");
const pnpmInfo = await pmUtil.getPnpmVersionInfo();
if (!pnpmInfo.available) {
console.log("pnpm is required for gitzone tools update, but it was not found.");
return;
}
console.log("Package manager:\n");
console.log(" Name Current Latest Status");
console.log(" ----------------------------------------------");
const latestPnpm = (pnpmInfo.latestVersion || "unknown").padEnd(12);
const pnpmStatus = pnpmInfo.latestVersion === null
? "? Version unknown"
: pnpmInfo.needsUpdate
? "Update available"
: "Up to date";
console.log(` ${"pnpm".padEnd(9)}${pnpmInfo.currentVersion.padEnd(12)}${latestPnpm}${pnpmStatus}`);
console.log("");
if (verbose) {
console.log("Using pnpm as the supported global package manager.\n");
}
const selfUpdated = await handleSelfUpdate(pmUtil, mode);
if (selfUpdated) {
return;
}
const installedPackages = await pmUtil.getInstalledPackages();
const packageInfos = await getPackageUpdateInfos(pmUtil, installedPackages);
if (packageInfos.length === 0) {
console.log("No managed @git.zone packages found installed globally.");
return;
}
console.log("Installed @git.zone packages:\n");
console.log(" Package Current Latest Status");
console.log(" ------------------------------------------------------------");
for (const packageInfo of packageInfos) {
const status = packageInfo.latestVersion === "unknown"
? "? Version unknown"
: packageInfo.needsUpdate
? "Update available"
: "Up to date";
console.log(
` ${packageInfo.name.padEnd(28)}${packageInfo.currentVersion.padEnd(12)}${packageInfo.latestVersion.padEnd(12)}${status}`,
);
}
console.log("");
await printMissingPackages(pmUtil, installedPackages);
const packagesToUpdate = packageInfos.filter((packageInfo) => packageInfo.needsUpdate);
if (packagesToUpdate.length === 0) {
console.log("All managed packages are up to date.");
return;
}
console.log(`Found ${packagesToUpdate.length} package(s) with available updates.\n`);
if (!mode.yes && !mode.interactive) {
console.log("Run gitzone tools update -y to update without prompts.");
return;
}
let shouldUpdate = mode.yes;
if (!shouldUpdate) {
const interactInstance = new plugins.smartinteract.SmartInteract();
const answer = await interactInstance.askQuestion({
type: "confirm",
name: "confirmUpdate",
message: "Do you want to update these packages?",
default: true,
});
shouldUpdate = answer.value === true;
}
if (!shouldUpdate) {
console.log("Update cancelled.");
return;
}
await installPackages(pmUtil, packagesToUpdate.map((packageInfo) => packageInfo.name), "updated");
}
async function runInstall(argvArg: any, mode: ICliMode): Promise<void> {
const verbose = Boolean(argvArg.v || argvArg.verbose);
const pmUtil = new PackageManagerUtil();
console.log("Scanning for missing @git.zone packages...\n");
const pnpmAvailable = await pmUtil.detectPnpm();
if (!pnpmAvailable) {
console.log("pnpm is required for gitzone tools install, but it was not found.");
return;
}
if (verbose) {
console.log("Using pnpm as the supported global package manager.\n");
}
const installedPackages = await pmUtil.getInstalledPackages();
const installedNames = new Set(installedPackages.map((packageInfo) => packageInfo.name));
const missingPackages = GITZONE_PACKAGES.filter((packageName) => !installedNames.has(packageName));
if (missingPackages.length === 0) {
console.log("All managed @git.zone packages are already installed.");
return;
}
console.log(`Found ${missingPackages.length} missing package(s).\n`);
if (!mode.yes && !mode.interactive) {
await printPackageListWithLatest(pmUtil, missingPackages);
console.log("Run gitzone tools install -y to install all missing packages without prompts.");
return;
}
let selectedPackages = missingPackages;
if (!mode.yes) {
const choicesWithVersions: Array<{ name: string; value: string }> = [];
for (const packageName of missingPackages) {
const latest = await pmUtil.getLatestVersion(packageName);
choicesWithVersions.push({
name: `${packageName}${latest ? `@${latest}` : ""}`,
value: packageName,
});
}
const interactInstance = new plugins.smartinteract.SmartInteract();
const answer = await interactInstance.askQuestion({
type: "checkbox",
name: "packages",
message: "Select packages to install:",
default: missingPackages,
choices: choicesWithVersions,
});
selectedPackages = answer.value as string[];
if (selectedPackages.length === 0) {
console.log("No packages selected. Nothing to install.");
return;
}
}
await installPackages(pmUtil, selectedPackages, "installed");
}
async function handleSelfUpdate(
pmUtil: PackageManagerUtil,
mode: ICliMode,
): Promise<boolean> {
console.log("Checking for gitzone self-update...\n");
const currentVersion = commitinfo.version;
const latestVersion = await pmUtil.getLatestVersion("@git.zone/cli");
if (!latestVersion || !pmUtil.isNewerVersion(currentVersion, latestVersion)) {
console.log(` @git.zone/cli ${currentVersion} Up to date\n`);
return false;
}
console.log(` @git.zone/cli ${currentVersion} -> ${latestVersion} Update available\n`);
if (!mode.yes && !mode.interactive) {
console.log("Run gitzone tools update -y to update gitzone first.");
return true;
}
let shouldUpdate = mode.yes;
if (!shouldUpdate) {
const interactInstance = new plugins.smartinteract.SmartInteract();
const answer = await interactInstance.askQuestion({
type: "confirm",
name: "confirmSelfUpdate",
message: "Do you want to update gitzone itself first?",
default: true,
});
shouldUpdate = answer.value === true;
}
if (!shouldUpdate) {
console.log("Skipping gitzone self-update.\n");
return false;
}
const success = await pmUtil.installLatest("@git.zone/cli");
if (!success) {
console.log("\ngitzone self-update failed. Continuing with the current version.\n");
return false;
}
console.log("\ngitzone has been updated. Re-run gitzone tools update to check remaining packages.");
return true;
}
async function getPackageUpdateInfos(
pmUtil: PackageManagerUtil,
installedPackages: IInstalledPackage[],
): Promise<IPackageUpdateInfo[]> {
const packageInfos: IPackageUpdateInfo[] = [];
for (const installedPackage of installedPackages) {
if (!GITZONE_PACKAGES.includes(installedPackage.name)) {
continue;
}
const latestVersion = await pmUtil.getLatestVersion(installedPackage.name);
packageInfos.push({
name: installedPackage.name,
currentVersion: installedPackage.version,
latestVersion: latestVersion || "unknown",
needsUpdate: latestVersion
? pmUtil.isNewerVersion(installedPackage.version, latestVersion)
: false,
});
}
return packageInfos;
}
async function printMissingPackages(
pmUtil: PackageManagerUtil,
installedPackages: IInstalledPackage[],
): Promise<void> {
const installedNames = new Set(installedPackages.map((packageInfo) => packageInfo.name));
const missingPackages = GITZONE_PACKAGES.filter((packageName) => !installedNames.has(packageName));
if (missingPackages.length === 0) {
return;
}
console.log("Not installed (managed @git.zone packages):\n");
await printPackageListWithLatest(pmUtil, missingPackages);
console.log("Run gitzone tools install to install missing packages.\n");
}
async function printPackageListWithLatest(
pmUtil: PackageManagerUtil,
packageNames: string[],
): Promise<void> {
console.log(" Package Latest");
console.log(" ----------------------------------------");
for (const packageName of packageNames) {
const latest = await pmUtil.getLatestVersion(packageName);
console.log(` ${packageName.padEnd(28)} ${latest || "unknown"}`);
}
console.log("");
}
async function installPackages(
pmUtil: PackageManagerUtil,
packageNames: string[],
action: "installed" | "updated",
): Promise<void> {
let successCount = 0;
let failCount = 0;
for (const packageName of packageNames) {
const success = await pmUtil.installLatest(packageName);
if (success) {
console.log(` ${packageName} ${action} successfully`);
successCount++;
} else {
console.log(` ${packageName} failed`);
failCount++;
}
}
console.log("");
if (failCount === 0) {
console.log(`All ${successCount} package(s) ${action} successfully.`);
} else {
console.log(`${successCount} package(s) ${action}, ${failCount} failed.`);
}
}
export function showHelp(mode?: ICliMode): void {
if (mode?.json) {
printJson({
name: "gitzone tools",
usage: "gitzone tools <command> [options]",
commands: [
{ name: "update", description: "Check and update globally installed @git.zone packages" },
{ name: "install", description: "Install missing managed @git.zone packages" },
],
flags: [
{ flag: "-y, --yes", description: "Run without confirmation prompts" },
{ flag: "-v, --verbose", description: "Show package manager diagnostics" },
],
packageManager: "pnpm",
managedPackages: GITZONE_PACKAGES,
});
return;
}
console.log("");
console.log("Usage: gitzone tools <command> [options]");
console.log("");
console.log("Commands:");
console.log(" update Check and update globally installed @git.zone packages");
console.log(" install Install missing managed @git.zone packages");
console.log("");
console.log("Options:");
console.log(" -y, --yes Run without confirmation prompts");
console.log(" -v, --verbose Show package manager diagnostics");
console.log("");
console.log("Examples:");
console.log(" gitzone tools update");
console.log(" gitzone tools update -y");
console.log(" gitzone tools install");
console.log("");
}
+1
View File
@@ -0,0 +1 @@
export * from "../plugins.js";