uva-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/bin.mjs +56 -0
- package/commands/branch.mjs +138 -0
- package/commands/commit.mjs +136 -0
- package/commands/init.mjs +160 -0
- package/commands/new-file.mjs +240 -0
- package/commands/push.mjs +31 -0
- package/commands/start.mjs +63 -0
- package/lib/banner.mjs +15 -0
- package/lib/colors.mjs +50 -0
- package/lib/config.mjs +34 -0
- package/lib/format.mjs +26 -0
- package/lib/git.mjs +67 -0
- package/lib/slugify.mjs +14 -0
- package/lib/types.mjs +45 -0
- package/package.json +47 -0
- package/templates/en/docs/adr.md +45 -0
- package/templates/en/docs/decision-log.md +26 -0
- package/templates/en/docs/meeting-notes.md +32 -0
- package/templates/en/docs/rfc.md +39 -0
- package/templates/en/docs/runbook.md +46 -0
- package/templates/en/git/pull-request.md +37 -0
- package/templates/pt-br/docs/adr.md +44 -0
- package/templates/pt-br/docs/decision-log.md +26 -0
- package/templates/pt-br/docs/meeting-notes.md +32 -0
- package/templates/pt-br/docs/rfc.md +39 -0
- package/templates/pt-br/docs/runbook.md +46 -0
- package/templates/pt-br/git/pull-request.md +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 UVA CLI Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/uva-cli-lockup.svg" alt="uva-cli" width="420" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<strong>Interactive Git workflow automation for teams</strong><br>
|
|
7
|
+
Branch naming, Conventional Commits, and more — all guided, all configurable.
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<p align="center">
|
|
11
|
+
<a href="https://npmjs.com/package/uva-cli">
|
|
12
|
+
<img src="https://img.shields.io/npm/v/uva-cli.svg?color=6D28D9&label=npm" alt="npm version" />
|
|
13
|
+
</a>
|
|
14
|
+
<a href="LICENSE">
|
|
15
|
+
<img src="https://img.shields.io/badge/license-MIT-0F9D6B.svg" alt="MIT license" />
|
|
16
|
+
</a>
|
|
17
|
+
<img src="https://img.shields.io/badge/node-%3E%3D18-A78BFA.svg" alt="Node.js ≥18" />
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## What is UVA CLI?
|
|
23
|
+
|
|
24
|
+
UVA CLI turns repetitive Git operations into short, interactive wizards. Instead of memorizing your team's branch naming conventions or commit message formats, the CLI guides you through them — and adapts to whatever conventions your project uses.
|
|
25
|
+
|
|
26
|
+
No more "how should I name this branch?" or "what goes in the ticket field?". Just run `uva` and follow the prompts.
|
|
27
|
+
|
|
28
|
+
## Features
|
|
29
|
+
|
|
30
|
+
- **Per-project config** — run `uva init` once; conventions are saved to `uva.config.json`
|
|
31
|
+
- **Conventional Commits** — pick the type, add a ticket (optional), write the message
|
|
32
|
+
- **Branch creation** — checks out the source branch, pulls, and creates your new branch in one step
|
|
33
|
+
- **Multiple formats** — commit and branch naming patterns are chosen during setup, not hard-coded
|
|
34
|
+
- **Zero lock-in** — conventions changed? Re-run `uva init`
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install -g uva-cli
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or run without a global install:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx uva-cli init
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Command not found?
|
|
49
|
+
|
|
50
|
+
If your shell can't find `uva` after a global install, the npm global bin directory isn't in your `PATH`. Add this to your `~/.zshrc` (or `~/.bashrc`):
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
export PATH="$(npm prefix -g)/bin:$PATH"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Then reload your shell:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
source ~/.zshrc
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Quick start
|
|
63
|
+
|
|
64
|
+
### 1. Set up your project
|
|
65
|
+
|
|
66
|
+
Run this once inside any Git repository:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
uva init
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
You'll be asked:
|
|
73
|
+
|
|
74
|
+
- Project name
|
|
75
|
+
- Do you use a ticket tracker? (Jira, Linear, GitHub Issues…)
|
|
76
|
+
- Ticket prefix (e.g. `PROJ`, `ENG`, `FC`)
|
|
77
|
+
- Commit message format
|
|
78
|
+
- Branch naming format
|
|
79
|
+
- Which branches you can branch from (e.g. `main,develop`)
|
|
80
|
+
|
|
81
|
+
This creates a **`uva.config.json`** at your repository root. Commit it — everyone on your team shares the same setup automatically.
|
|
82
|
+
|
|
83
|
+
### 2. Use the commands
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
uva start # interactive menu — pick what to do
|
|
87
|
+
uva commit # guided commit: type → ticket → message → file selection
|
|
88
|
+
uva branch # guided branch: source → ticket → type → task name
|
|
89
|
+
uva push # confirms and pushes the current branch to origin
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Commands
|
|
93
|
+
|
|
94
|
+
| Command | Description |
|
|
95
|
+
| :------------- | :---------------------------------------------------------- |
|
|
96
|
+
| `uva init` | Set up UVA CLI for this project — creates `uva.config.json` |
|
|
97
|
+
| `uva start` | Interactive menu with all available options |
|
|
98
|
+
| `uva commit` | Create an interactive commit following Conventional Commits |
|
|
99
|
+
| `uva branch` | Create a new branch from a configured source branch |
|
|
100
|
+
| `uva new-file` | Scaffold a file from a template (docs, frontend, backend) |
|
|
101
|
+
| `uva push` | Push the current branch to origin |
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
`uva.config.json` is created by `uva init` and lives at the root of your repository. You can edit it by hand at any time.
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"project": {
|
|
110
|
+
"name": "My Project"
|
|
111
|
+
},
|
|
112
|
+
"commit": {
|
|
113
|
+
"ticketEnabled": true,
|
|
114
|
+
"ticketPrefix": "PROJ",
|
|
115
|
+
"ticketPlaceholder": "PROJ-42",
|
|
116
|
+
"format": "conventional-ticket"
|
|
117
|
+
},
|
|
118
|
+
"branch": {
|
|
119
|
+
"sources": ["main", "develop"],
|
|
120
|
+
"ticketEnabled": true,
|
|
121
|
+
"areaEnabled": false,
|
|
122
|
+
"areas": [],
|
|
123
|
+
"format": "type-ticket-name"
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Commit formats
|
|
129
|
+
|
|
130
|
+
| `format` value | Example output |
|
|
131
|
+
| :-------------------- | :--------------------------------- |
|
|
132
|
+
| `conventional-ticket` | `feat(PROJ-42): add login screen` |
|
|
133
|
+
| `conventional` | `feat: add login screen` |
|
|
134
|
+
| `ticket-conventional` | `[PROJ-42] feat: add login screen` |
|
|
135
|
+
|
|
136
|
+
### Branch formats
|
|
137
|
+
|
|
138
|
+
| `format` value | Example output |
|
|
139
|
+
| :----------------- | :------------------------------ |
|
|
140
|
+
| `type-ticket-name` | `feat/PROJ-42_add-login-screen` |
|
|
141
|
+
| `type-name` | `feat/add-login-screen` |
|
|
142
|
+
| `ticket-type-name` | `PROJ-42/feat/add-login-screen` |
|
|
143
|
+
|
|
144
|
+
## File templates (`uva new-file`)
|
|
145
|
+
|
|
146
|
+
`uva new-file` scaffolds files from opinionated templates. The flow is: **language → category → type → title → folder**. Date and author (from `git config`) are filled in automatically.
|
|
147
|
+
|
|
148
|
+
Available languages: **English** and **Português (BR)**.
|
|
149
|
+
|
|
150
|
+
### Docs
|
|
151
|
+
|
|
152
|
+
Documentation templates in Markdown, meant to live alongside your source code in a `docs/` folder.
|
|
153
|
+
|
|
154
|
+
| Type | When to use | Default output |
|
|
155
|
+
| :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------- |
|
|
156
|
+
| **ADR** | Record an architectural decision: context, decision, rationale, consequences, and alternatives. Based on [Michael Nygard's format](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions). | `docs/decisions/<slug>.md` |
|
|
157
|
+
| **Decision Log** | Lightweight alternative to ADRs — good for product, process, or quick technical calls. | `docs/decisions/<slug>.md` |
|
|
158
|
+
| **Meeting Notes** | Agenda, discussion notes, decisions made, and action items in one place. | `docs/meetings/<slug>.md` |
|
|
159
|
+
| **RFC** | Propose a significant change before work starts. Captures motivation, design, drawbacks, and alternatives. Inspired by the Rust and React RFC processes. | `docs/rfcs/<slug>.md` |
|
|
160
|
+
| **Runbook** | Step-by-step operational guide for a known scenario — incident response, deployment, rollback. | `docs/runbooks/<slug>.md` |
|
|
161
|
+
|
|
162
|
+
### Git
|
|
163
|
+
|
|
164
|
+
GitHub workflow files written to the standard paths GitHub expects.
|
|
165
|
+
|
|
166
|
+
| Type | When to use | Output |
|
|
167
|
+
| :-------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------ | :--------------------------------- |
|
|
168
|
+
| **PR Template** | Standardize pull request descriptions across the team. Includes title format, dependency declaration, change description, test instructions, and a checklist. | `.github/pull_request_template.md` |
|
|
169
|
+
|
|
170
|
+
## Requirements
|
|
171
|
+
|
|
172
|
+
- Node.js ≥ 18
|
|
173
|
+
- Git
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
[MIT](LICENSE)
|
package/bin.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander'
|
|
3
|
+
import { runInit } from './commands/init.mjs'
|
|
4
|
+
import { runCommit } from './commands/commit.mjs'
|
|
5
|
+
import { runBranch } from './commands/branch.mjs'
|
|
6
|
+
import { runNewFile } from './commands/new-file.mjs'
|
|
7
|
+
import { runPush } from './commands/push.mjs'
|
|
8
|
+
import { runStart } from './commands/start.mjs'
|
|
9
|
+
|
|
10
|
+
const program = new Command()
|
|
11
|
+
|
|
12
|
+
program.name('uva').description('UVA CLI — Git workflow automation').version('1.0.0')
|
|
13
|
+
|
|
14
|
+
program.command('start').description('Show all available options interactively').action(runStart)
|
|
15
|
+
|
|
16
|
+
program.command('init').description('Set up UVA CLI for this project').action(runInit)
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.command('commit')
|
|
20
|
+
.description('Create an interactive commit following Conventional Commits')
|
|
21
|
+
.option('--type <type>', 'commit type (feat, fix, docs, style, refactor, test, chore)')
|
|
22
|
+
.option('--ticket <id>', 'ticket ID, e.g. PROJ-42')
|
|
23
|
+
.option('--message <msg>', 'commit message')
|
|
24
|
+
.option('--files <paths>', 'files to stage, comma-separated paths')
|
|
25
|
+
.option('--all', 'stage all changed files')
|
|
26
|
+
.action(runCommit)
|
|
27
|
+
|
|
28
|
+
program
|
|
29
|
+
.command('branch')
|
|
30
|
+
.description('Create a new branch from a configured source')
|
|
31
|
+
.option('--source <branch>', 'source branch to checkout from')
|
|
32
|
+
.option('--ticket <id>', 'ticket ID, e.g. PROJ-42')
|
|
33
|
+
.option('--type <type>', 'branch type (feat, fix, docs, style, refactor, test, chore)')
|
|
34
|
+
.option('--name <name>', 'task name (will be slugified into the branch name)')
|
|
35
|
+
.action(runBranch)
|
|
36
|
+
|
|
37
|
+
program
|
|
38
|
+
.command('new-file')
|
|
39
|
+
.description('Scaffold a file from a template (docs, git)')
|
|
40
|
+
.option('--lang <lang>', 'language: en | pt-br')
|
|
41
|
+
.option('--category <category>', 'category: docs | git')
|
|
42
|
+
.option(
|
|
43
|
+
'--type <type>',
|
|
44
|
+
'file type: adr | decision-log | meeting-notes | rfc | runbook | pull-request',
|
|
45
|
+
)
|
|
46
|
+
.option('--name <name>', 'file title (docs only)')
|
|
47
|
+
.option('--folder <path>', 'output folder path (docs only)')
|
|
48
|
+
.action(runNewFile)
|
|
49
|
+
|
|
50
|
+
program
|
|
51
|
+
.command('push')
|
|
52
|
+
.description('Push the current branch to origin')
|
|
53
|
+
.option('-y, --yes', 'skip confirmation and push immediately')
|
|
54
|
+
.action(runPush)
|
|
55
|
+
|
|
56
|
+
program.parse()
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { select, text, confirm, isCancel, log } from '@clack/prompts'
|
|
2
|
+
import { bannerIntro, bannerOutro, bannerCancelled } from '../lib/banner.mjs'
|
|
3
|
+
import { hasUncommittedChanges, checkout, pull, createBranch } from '../lib/git.mjs'
|
|
4
|
+
import { COMMIT_TYPES } from '../lib/types.mjs'
|
|
5
|
+
import { requireConfig } from '../lib/config.mjs'
|
|
6
|
+
import { buildBranchName } from '../lib/format.mjs'
|
|
7
|
+
|
|
8
|
+
// Called by commander as runBranch(options, command)
|
|
9
|
+
export async function runBranch(opts = {}) {
|
|
10
|
+
bannerIntro('branch')
|
|
11
|
+
|
|
12
|
+
const config = requireConfig()
|
|
13
|
+
|
|
14
|
+
if (hasUncommittedChanges()) {
|
|
15
|
+
const proceed = await confirm({
|
|
16
|
+
message: 'You have uncommitted changes. They will carry over to the new branch. Continue?',
|
|
17
|
+
})
|
|
18
|
+
if (isCancel(proceed) || !proceed) {
|
|
19
|
+
log.info('Tip: run `uva commit` first to commit pending changes.')
|
|
20
|
+
bannerCancelled()
|
|
21
|
+
process.exit(0)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── Source branch ────────────────────────────────────────────────────────
|
|
26
|
+
let source
|
|
27
|
+
if (opts.source) {
|
|
28
|
+
if (!config.branch.sources.includes(opts.source)) {
|
|
29
|
+
log.warn(`"${opts.source}" is not in the configured source branches. Proceeding anyway.`)
|
|
30
|
+
}
|
|
31
|
+
source = opts.source
|
|
32
|
+
} else {
|
|
33
|
+
source = await select({
|
|
34
|
+
message: 'Branch from',
|
|
35
|
+
options: config.branch.sources.map((b) => ({ value: b, label: b })),
|
|
36
|
+
})
|
|
37
|
+
if (isCancel(source)) {
|
|
38
|
+
bannerCancelled()
|
|
39
|
+
process.exit(0)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Ticket ───────────────────────────────────────────────────────────────
|
|
44
|
+
let ticket = ''
|
|
45
|
+
if (config.branch.ticketEnabled) {
|
|
46
|
+
if (opts.ticket) {
|
|
47
|
+
ticket = opts.ticket
|
|
48
|
+
} else {
|
|
49
|
+
const input = await text({
|
|
50
|
+
message: 'Ticket',
|
|
51
|
+
placeholder: config.commit.ticketPlaceholder,
|
|
52
|
+
validate: (v) => (v.trim() ? undefined : 'Ticket cannot be empty.'),
|
|
53
|
+
})
|
|
54
|
+
if (isCancel(input)) {
|
|
55
|
+
bannerCancelled()
|
|
56
|
+
process.exit(0)
|
|
57
|
+
}
|
|
58
|
+
ticket = input.trim()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Branch type ──────────────────────────────────────────────────────────
|
|
63
|
+
let type
|
|
64
|
+
if (opts.type) {
|
|
65
|
+
const valid = COMMIT_TYPES.map((t) => t.value)
|
|
66
|
+
if (!valid.includes(opts.type)) {
|
|
67
|
+
log.error(`Invalid --type "${opts.type}". Valid values: ${valid.join(', ')}`)
|
|
68
|
+
process.exit(1)
|
|
69
|
+
}
|
|
70
|
+
type = opts.type
|
|
71
|
+
} else {
|
|
72
|
+
type = await select({ message: 'Branch type', options: COMMIT_TYPES })
|
|
73
|
+
if (isCancel(type)) {
|
|
74
|
+
bannerCancelled()
|
|
75
|
+
process.exit(0)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Task name ────────────────────────────────────────────────────────────
|
|
80
|
+
let taskName
|
|
81
|
+
if (opts.name) {
|
|
82
|
+
taskName = opts.name
|
|
83
|
+
} else {
|
|
84
|
+
const input = await text({
|
|
85
|
+
message: 'Task name',
|
|
86
|
+
placeholder: 'add login screen',
|
|
87
|
+
validate: (v) => (v.trim() ? undefined : 'Task name cannot be empty.'),
|
|
88
|
+
})
|
|
89
|
+
if (isCancel(input)) {
|
|
90
|
+
bannerCancelled()
|
|
91
|
+
process.exit(0)
|
|
92
|
+
}
|
|
93
|
+
taskName = input.trim()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const branchName = buildBranchName(config.branch.format, { type, ticket, name: taskName })
|
|
97
|
+
|
|
98
|
+
// Skip confirmation when fully scripted
|
|
99
|
+
const ticketDone = !config.branch.ticketEnabled || opts.ticket
|
|
100
|
+
const scripted = opts.source && opts.type && opts.name && ticketDone
|
|
101
|
+
|
|
102
|
+
if (!scripted) {
|
|
103
|
+
const confirmed = await confirm({ message: `Create branch:\n ${branchName}` })
|
|
104
|
+
if (isCancel(confirmed) || !confirmed) {
|
|
105
|
+
bannerCancelled()
|
|
106
|
+
process.exit(0)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
checkout(source)
|
|
112
|
+
} catch {
|
|
113
|
+
log.error(
|
|
114
|
+
`Could not switch to ${source}. Make sure the branch exists and there are no conflicts.`,
|
|
115
|
+
)
|
|
116
|
+
process.exit(1)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
pull()
|
|
121
|
+
} catch {
|
|
122
|
+
log.error('Could not pull. Check your connection or resolve any conflicts.')
|
|
123
|
+
process.exit(1)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
createBranch(branchName)
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (err.stderr?.includes('already exists') || err.message?.includes('already exists')) {
|
|
130
|
+
log.error(`Branch "${branchName}" already exists. Choose a different name or ticket.`)
|
|
131
|
+
} else {
|
|
132
|
+
log.error('Could not create the branch. Check if the name is valid.')
|
|
133
|
+
}
|
|
134
|
+
process.exit(1)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
bannerOutro(`Branch created: ${branchName}`)
|
|
138
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { select, text, multiselect, confirm, isCancel, log } from '@clack/prompts'
|
|
2
|
+
import { bannerIntro, bannerOutro, bannerCancelled } from '../lib/banner.mjs'
|
|
3
|
+
import { getChangedFiles, addFiles, commit } from '../lib/git.mjs'
|
|
4
|
+
import { COMMIT_TYPES } from '../lib/types.mjs'
|
|
5
|
+
import { requireConfig } from '../lib/config.mjs'
|
|
6
|
+
import { buildCommitMessage } from '../lib/format.mjs'
|
|
7
|
+
|
|
8
|
+
// Called by commander as runCommit(options, command)
|
|
9
|
+
export async function runCommit(opts = {}) {
|
|
10
|
+
bannerIntro('commit')
|
|
11
|
+
|
|
12
|
+
const config = requireConfig()
|
|
13
|
+
const files = getChangedFiles()
|
|
14
|
+
|
|
15
|
+
if (files.length === 0) {
|
|
16
|
+
log.info('Nothing to commit here.')
|
|
17
|
+
process.exit(0)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── Commit type ──────────────────────────────────────────────────────────
|
|
21
|
+
let type
|
|
22
|
+
if (opts.type) {
|
|
23
|
+
const valid = COMMIT_TYPES.map((t) => t.value)
|
|
24
|
+
if (!valid.includes(opts.type)) {
|
|
25
|
+
log.error(`Invalid --type "${opts.type}". Valid values: ${valid.join(', ')}`)
|
|
26
|
+
process.exit(1)
|
|
27
|
+
}
|
|
28
|
+
type = opts.type
|
|
29
|
+
} else {
|
|
30
|
+
type = await select({ message: 'Commit type', options: COMMIT_TYPES })
|
|
31
|
+
if (isCancel(type)) {
|
|
32
|
+
bannerCancelled()
|
|
33
|
+
process.exit(0)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Ticket ───────────────────────────────────────────────────────────────
|
|
38
|
+
let ticket = ''
|
|
39
|
+
if (config.commit.ticketEnabled) {
|
|
40
|
+
if (opts.ticket) {
|
|
41
|
+
ticket = opts.ticket
|
|
42
|
+
} else {
|
|
43
|
+
const input = await text({
|
|
44
|
+
message: 'Ticket',
|
|
45
|
+
placeholder: config.commit.ticketPlaceholder,
|
|
46
|
+
validate: (v) => (v.trim() ? undefined : 'Ticket cannot be empty.'),
|
|
47
|
+
})
|
|
48
|
+
if (isCancel(input)) {
|
|
49
|
+
bannerCancelled()
|
|
50
|
+
process.exit(0)
|
|
51
|
+
}
|
|
52
|
+
ticket = input.trim()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Message + files (retry loop for interactive use) ─────────────────────
|
|
57
|
+
let finalMessage
|
|
58
|
+
let selected
|
|
59
|
+
|
|
60
|
+
while (true) {
|
|
61
|
+
// Message
|
|
62
|
+
let message
|
|
63
|
+
if (opts.message) {
|
|
64
|
+
message = opts.message.trim().toLowerCase()
|
|
65
|
+
} else {
|
|
66
|
+
const input = await text({
|
|
67
|
+
message: 'Commit message',
|
|
68
|
+
placeholder: 'add login screen',
|
|
69
|
+
validate: (v) => (v.trim() ? undefined : 'Message cannot be empty.'),
|
|
70
|
+
})
|
|
71
|
+
if (isCancel(input)) {
|
|
72
|
+
bannerCancelled()
|
|
73
|
+
process.exit(0)
|
|
74
|
+
}
|
|
75
|
+
message = input.trim().toLowerCase()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (message.length > 72) {
|
|
79
|
+
log.warn(
|
|
80
|
+
'Message is long (over 72 characters). Shorter messages are recommended, but you can continue.',
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Files
|
|
85
|
+
if (opts.all) {
|
|
86
|
+
selected = files.map((f) => f.path)
|
|
87
|
+
} else if (opts.files) {
|
|
88
|
+
const paths = opts.files
|
|
89
|
+
.split(',')
|
|
90
|
+
.map((s) => s.trim())
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
const availablePaths = files.map((f) => f.path)
|
|
93
|
+
const invalid = paths.filter((p) => !availablePaths.includes(p))
|
|
94
|
+
if (invalid.length) {
|
|
95
|
+
log.error(`Unknown file(s): ${invalid.join(', ')}\nAvailable: ${availablePaths.join(', ')}`)
|
|
96
|
+
process.exit(1)
|
|
97
|
+
}
|
|
98
|
+
selected = paths
|
|
99
|
+
} else {
|
|
100
|
+
selected = await multiselect({
|
|
101
|
+
message: 'Which files to include in the commit?',
|
|
102
|
+
options: files.map((f) => ({ value: f.path, label: `${f.status.padEnd(2)} ${f.path}` })),
|
|
103
|
+
required: true,
|
|
104
|
+
})
|
|
105
|
+
if (isCancel(selected)) {
|
|
106
|
+
bannerCancelled()
|
|
107
|
+
process.exit(0)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
finalMessage = buildCommitMessage(config.commit.format, { type, ticket, message })
|
|
112
|
+
|
|
113
|
+
// Skip confirmation when fully scripted (all values came from flags)
|
|
114
|
+
const scripted = opts.type && opts.message && (opts.all || opts.files)
|
|
115
|
+
if (scripted) break
|
|
116
|
+
|
|
117
|
+
const confirmed = await confirm({ message: `Commit with message:\n ${finalMessage}` })
|
|
118
|
+
if (isCancel(confirmed)) {
|
|
119
|
+
bannerCancelled()
|
|
120
|
+
process.exit(0)
|
|
121
|
+
}
|
|
122
|
+
if (confirmed) break
|
|
123
|
+
|
|
124
|
+
if (opts.message) break // message is fixed, can't retry
|
|
125
|
+
log.info("OK! Let's rewrite the message.")
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
addFiles(selected)
|
|
130
|
+
commit(finalMessage)
|
|
131
|
+
bannerOutro(`Committed: ${finalMessage}`)
|
|
132
|
+
} catch (e) {
|
|
133
|
+
log.error(`Commit failed: ${e.message}`)
|
|
134
|
+
process.exit(1)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { text, select, confirm, isCancel } from '@clack/prompts'
|
|
2
|
+
import { bannerIntro, bannerOutro, bannerCancelled } from '../lib/banner.mjs'
|
|
3
|
+
import { loadConfig, saveConfig } from '../lib/config.mjs'
|
|
4
|
+
import { COMMIT_FORMATS, BRANCH_FORMATS } from '../lib/types.mjs'
|
|
5
|
+
|
|
6
|
+
export async function runInit() {
|
|
7
|
+
bannerIntro('init')
|
|
8
|
+
|
|
9
|
+
const existing = loadConfig()
|
|
10
|
+
if (existing) {
|
|
11
|
+
const overwrite = await confirm({
|
|
12
|
+
message: 'A configuration already exists. Do you want to overwrite it?',
|
|
13
|
+
})
|
|
14
|
+
if (isCancel(overwrite) || !overwrite) {
|
|
15
|
+
bannerCancelled()
|
|
16
|
+
process.exit(0)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const projectName = await text({
|
|
21
|
+
message: 'Project name',
|
|
22
|
+
placeholder: 'My Awesome Project',
|
|
23
|
+
validate: (v) => (v.trim() ? undefined : 'Project name cannot be empty.'),
|
|
24
|
+
})
|
|
25
|
+
if (isCancel(projectName)) {
|
|
26
|
+
bannerCancelled()
|
|
27
|
+
process.exit(0)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const useTicket = await confirm({
|
|
31
|
+
message: 'Do you use a ticket/issue tracker? (e.g., Jira, Linear, GitHub Issues)',
|
|
32
|
+
})
|
|
33
|
+
if (isCancel(useTicket)) {
|
|
34
|
+
bannerCancelled()
|
|
35
|
+
process.exit(0)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let ticketPrefix = ''
|
|
39
|
+
let ticketPlaceholder = ''
|
|
40
|
+
|
|
41
|
+
if (useTicket) {
|
|
42
|
+
const prefix = await text({
|
|
43
|
+
message: 'Ticket prefix',
|
|
44
|
+
placeholder: 'PROJ',
|
|
45
|
+
validate: (v) => (v.trim() ? undefined : 'Ticket prefix cannot be empty.'),
|
|
46
|
+
})
|
|
47
|
+
if (isCancel(prefix)) {
|
|
48
|
+
bannerCancelled()
|
|
49
|
+
process.exit(0)
|
|
50
|
+
}
|
|
51
|
+
ticketPrefix = prefix.trim().toUpperCase()
|
|
52
|
+
ticketPlaceholder = `${ticketPrefix}-42`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let commitFormat = 'conventional'
|
|
56
|
+
if (useTicket) {
|
|
57
|
+
const selectedCommitFormat = await select({
|
|
58
|
+
message: 'Choose a commit message format',
|
|
59
|
+
options: COMMIT_FORMATS.map((f) => ({
|
|
60
|
+
...f,
|
|
61
|
+
label: f.label.replace(/PROJ/g, ticketPrefix || 'PROJ'),
|
|
62
|
+
})),
|
|
63
|
+
})
|
|
64
|
+
if (isCancel(selectedCommitFormat)) {
|
|
65
|
+
bannerCancelled()
|
|
66
|
+
process.exit(0)
|
|
67
|
+
}
|
|
68
|
+
commitFormat = selectedCommitFormat
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const sourcesInput = await text({
|
|
72
|
+
message: 'Branches to branch from (comma-separated)',
|
|
73
|
+
placeholder: 'main,develop',
|
|
74
|
+
initialValue: 'main,develop',
|
|
75
|
+
validate: (v) => {
|
|
76
|
+
const parts = v
|
|
77
|
+
.split(',')
|
|
78
|
+
.map((s) => s.trim())
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
return parts.length > 0 ? undefined : 'At least one branch is required.'
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
if (isCancel(sourcesInput)) {
|
|
84
|
+
bannerCancelled()
|
|
85
|
+
process.exit(0)
|
|
86
|
+
}
|
|
87
|
+
const sources = sourcesInput
|
|
88
|
+
.split(',')
|
|
89
|
+
.map((s) => s.trim())
|
|
90
|
+
.filter(Boolean)
|
|
91
|
+
|
|
92
|
+
let branchFormat = 'type-name'
|
|
93
|
+
if (useTicket) {
|
|
94
|
+
const selectedBranchFormat = await select({
|
|
95
|
+
message: 'Choose a branch naming format',
|
|
96
|
+
options: BRANCH_FORMATS.map((f) => ({
|
|
97
|
+
...f,
|
|
98
|
+
label: f.label.replace(/PROJ/g, ticketPrefix || 'PROJ'),
|
|
99
|
+
})),
|
|
100
|
+
})
|
|
101
|
+
if (isCancel(selectedBranchFormat)) {
|
|
102
|
+
bannerCancelled()
|
|
103
|
+
process.exit(0)
|
|
104
|
+
}
|
|
105
|
+
branchFormat = selectedBranchFormat
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const useArea = await confirm({
|
|
109
|
+
message: 'Do you want to categorize branches by area? (e.g., FE, BE, DOC)',
|
|
110
|
+
initialValue: false,
|
|
111
|
+
})
|
|
112
|
+
if (isCancel(useArea)) {
|
|
113
|
+
bannerCancelled()
|
|
114
|
+
process.exit(0)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let areas = []
|
|
118
|
+
if (useArea) {
|
|
119
|
+
const areasInput = await text({
|
|
120
|
+
message: 'Area labels (comma-separated)',
|
|
121
|
+
placeholder: 'FE,BE,DOC',
|
|
122
|
+
initialValue: 'FE,BE,DOC',
|
|
123
|
+
validate: (v) => {
|
|
124
|
+
const parts = v
|
|
125
|
+
.split(',')
|
|
126
|
+
.map((s) => s.trim())
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
return parts.length > 0 ? undefined : 'At least one area is required.'
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
if (isCancel(areasInput)) {
|
|
132
|
+
bannerCancelled()
|
|
133
|
+
process.exit(0)
|
|
134
|
+
}
|
|
135
|
+
areas = areasInput
|
|
136
|
+
.split(',')
|
|
137
|
+
.map((s) => s.trim().toUpperCase())
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const config = {
|
|
142
|
+
project: { name: projectName.trim() },
|
|
143
|
+
commit: {
|
|
144
|
+
ticketEnabled: Boolean(useTicket),
|
|
145
|
+
ticketPrefix,
|
|
146
|
+
ticketPlaceholder,
|
|
147
|
+
format: commitFormat,
|
|
148
|
+
},
|
|
149
|
+
branch: {
|
|
150
|
+
sources,
|
|
151
|
+
ticketEnabled: Boolean(useTicket),
|
|
152
|
+
areaEnabled: Boolean(useArea),
|
|
153
|
+
areas,
|
|
154
|
+
format: branchFormat,
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const configPath = saveConfig(config)
|
|
159
|
+
bannerOutro(`Configuration saved to ${configPath}`)
|
|
160
|
+
}
|