@pc360/chlog 0.1.6
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/CHANGELOG.md +67 -0
- package/README.md +226 -0
- package/bin/chlog.js +92 -0
- package/lib/chlog-lib.js +111 -0
- package/lib/chlog-ui.js +225 -0
- package/package.json +20 -0
- package/test/chlog-cli.integration.test.js +161 -0
- package/test/chlog-lib.test.js +170 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on Keep a Changelog, and this project follows Semantic Versioning.
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- Removed Ink card border lines so help and status output no longer render inside a box.
|
|
11
|
+
- Added blank lines before and after help and error output to improve readability.
|
|
12
|
+
|
|
13
|
+
## [0.1.6] - 2026-02-14
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- Bumped package version to `0.1.6`.
|
|
17
|
+
|
|
18
|
+
## [0.1.5] - 2026-02-14
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- Bumped package version to `0.1.5`.
|
|
22
|
+
|
|
23
|
+
## [0.1.4] - 2026-02-14
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- Cleaned project files and repository content.
|
|
27
|
+
|
|
28
|
+
## [0.1.3] - 2026-02-14
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
- Improved help-menu text styling for better readability.
|
|
32
|
+
|
|
33
|
+
## [0.1.2] - 2026-02-14
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
- Added short CLI flags:
|
|
37
|
+
- `-t` for `--type`
|
|
38
|
+
- `-s` for `--scope`
|
|
39
|
+
- `-m` for `--message`
|
|
40
|
+
- `-f` for `--frontend`
|
|
41
|
+
- `-d` for `--dry-run`
|
|
42
|
+
- Expanded unit and integration test coverage for short flag handling.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
- Enhanced `chlog --help` content with clearer tool description and usage details.
|
|
46
|
+
|
|
47
|
+
## [0.1.1] - 2026-02-14
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
- Refactored CLI terminal rendering to use `ink` with styled output cards.
|
|
51
|
+
|
|
52
|
+
## [0.1.0] - 2026-02-13
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
- Initial release of `pc360-changelog-cli`.
|
|
56
|
+
- Core CLI flow for creating changelog entry files under:
|
|
57
|
+
- `changelog/unreleased/added`
|
|
58
|
+
- `changelog/unreleased/changed`
|
|
59
|
+
- `changelog/unreleased/fixed`
|
|
60
|
+
- `changelog/unreleased/removed`
|
|
61
|
+
- Argument parsing and validation for required flags.
|
|
62
|
+
- Timezone-aware timestamp generation (`Asia/Manila` default).
|
|
63
|
+
- Safe file creation using exclusive write mode.
|
|
64
|
+
- Unit test coverage for parser, validation, timestamp, and output line formatting.
|
|
65
|
+
|
|
66
|
+
### Changed
|
|
67
|
+
- Standardized output path naming from `changelogs/` to `changelog/`.
|
package/README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# PC360 Changelog CLI
|
|
2
|
+
|
|
3
|
+
Internal CLI tool for creating structured changelog entries under:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
changelog/unreleased/{added|changed|fixed|removed}
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The CLI now renders polished terminal cards using `ink` in interactive terminals, with plain-text fallback for scripts/CI.
|
|
10
|
+
|
|
11
|
+
Each entry file is automatically:
|
|
12
|
+
|
|
13
|
+
- Named using Asia/Manila timestamp (`YYYYMMDDHHmmss`)
|
|
14
|
+
- Placed in the correct type directory
|
|
15
|
+
- Written as a single clean line
|
|
16
|
+
- Prefixed with scope and layer (`Back-End` or `Front-End`)
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## 📦 Installation
|
|
21
|
+
|
|
22
|
+
### Option 1 — Install from `.tgz` package
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
npm install -g pc360-changelog-cli-0.1.6.tgz
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Option 2 — Install from project folder
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
npm install -g .
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
After installation, the `chlog` command will be globally available.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## 🚀 Usage
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
chlog --type <added|changed|fixed|removed> \
|
|
42
|
+
--scope <scope> \
|
|
43
|
+
--message "<description>" \
|
|
44
|
+
[--frontend]
|
|
45
|
+
|
|
46
|
+
# Short form
|
|
47
|
+
chlog -t <added|changed|fixed|removed> -s <scope> -m "<description>" [-f]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 🧩 Parameters
|
|
53
|
+
|
|
54
|
+
| Flag | Required | Description |
|
|
55
|
+
| ---------------- | -------- | --------------------------------------------------- |
|
|
56
|
+
| `-t, --type` | ✅ | Change type: `added`, `changed`, `fixed`, `removed` |
|
|
57
|
+
| `-s, --scope` | ✅ | Jira ticket or module name |
|
|
58
|
+
| `-m, --message` | ✅ | Description of the change |
|
|
59
|
+
| `-f, --frontend` | ❌ | Uses `[Front-End]` instead of `[Back-End]` |
|
|
60
|
+
| `-d, --dry-run` | ❌ | Preview file creation without writing |
|
|
61
|
+
| `-h, --help` | ❌ | Show help |
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 📝 Examples
|
|
66
|
+
|
|
67
|
+
### Back-End Change (Default)
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
chlog --type added --scope JES-33 --message "Add daily consolidated JE job"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Creates:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
changelog/unreleased/added/20260213124530
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
File content:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
[JES-33] [Back-End] Add daily consolidated JE job
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
### Front-End Change
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
chlog --type fixed --scope JES-45 --frontend --message "Fix table pagination issue"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
File content:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
[JES-45] [Front-End] Fix table pagination issue
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
### Module-Based Scope
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
chlog --type changed --scope "Credit Validation" --message "Improve credit limit computation"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
File content:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
[Credit Validation] [Back-End] Improve credit limit computation
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## 📂 Directory Structure
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
changelog/
|
|
119
|
+
└── unreleased/
|
|
120
|
+
├── added/
|
|
121
|
+
├── changed/
|
|
122
|
+
├── fixed/
|
|
123
|
+
└── removed/
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Each entry is stored as a timestamp-named file:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
YYYYMMDDHHmmss
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Timezone used: **Asia/Manila**
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## 🔐 Rules Enforced by CLI
|
|
137
|
+
|
|
138
|
+
- `--type` is required
|
|
139
|
+
- `--scope` is required
|
|
140
|
+
- `--message` is required
|
|
141
|
+
- Prevents overwriting existing files
|
|
142
|
+
- Automatically inserts `[Back-End]` or `[Front-End]`
|
|
143
|
+
- Generates timestamp using Asia/Manila timezone
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## 🧠 Recommended Conventions
|
|
148
|
+
|
|
149
|
+
Preferred scope format:
|
|
150
|
+
|
|
151
|
+
- Jira ticket: `JES-45`
|
|
152
|
+
- Module name: `"Credit Validation"`
|
|
153
|
+
|
|
154
|
+
Recommended message style:
|
|
155
|
+
|
|
156
|
+
- Use imperative mood
|
|
157
|
+
- Start with capital letter
|
|
158
|
+
- Do not end with period
|
|
159
|
+
|
|
160
|
+
Example:
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
Add dealer credit validation logic
|
|
164
|
+
Fix FR status blocking issue
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## 🧪 Dry Run Mode
|
|
170
|
+
|
|
171
|
+
Preview without creating file:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
chlog --type added --scope JES-99 --message "Test entry" --dry-run
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## 🛠 Node Version
|
|
180
|
+
|
|
181
|
+
Requires:
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
Node.js >= 18
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Runtime dependencies:
|
|
188
|
+
|
|
189
|
+
- `ink`
|
|
190
|
+
- `react`
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## 🧪 Running Unit Tests
|
|
195
|
+
|
|
196
|
+
This project uses Node.js built-in test runner (`node:test`). No external test framework is required.
|
|
197
|
+
|
|
198
|
+
### Run Tests
|
|
199
|
+
|
|
200
|
+
From the project root:
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
npm test
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Or directly with Node:
|
|
207
|
+
|
|
208
|
+
```
|
|
209
|
+
node --test
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### What Is Tested
|
|
213
|
+
|
|
214
|
+
- Argument parsing (`parseArgs`)
|
|
215
|
+
- Required flag validation (`validateArgs`)
|
|
216
|
+
- Timezone validation
|
|
217
|
+
- Timestamp format generation (14-digit format)
|
|
218
|
+
- Output line formatting
|
|
219
|
+
- Front-End vs Back-End label handling
|
|
220
|
+
- Prevention of double label prefixing
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## 👨💻 Maintainer
|
|
225
|
+
|
|
226
|
+
- almerleoalmazan@gmail.com
|
package/bin/chlog.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const fs = require("fs/promises");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
parseArgs,
|
|
9
|
+
validateArgs,
|
|
10
|
+
generateTimestamp,
|
|
11
|
+
buildLine,
|
|
12
|
+
} = require("../lib/chlog-lib");
|
|
13
|
+
const {
|
|
14
|
+
renderHelp,
|
|
15
|
+
renderDryRun,
|
|
16
|
+
renderSuccess,
|
|
17
|
+
renderError,
|
|
18
|
+
} = require("../lib/chlog-ui");
|
|
19
|
+
|
|
20
|
+
function getHelpText() {
|
|
21
|
+
return `
|
|
22
|
+
Create timestamped changelog entries for release notes and audit tracking.
|
|
23
|
+
|
|
24
|
+
This command writes a single-line entry file under:
|
|
25
|
+
changelog/unreleased/<type>/<YYYYMMDDHHmmss>
|
|
26
|
+
|
|
27
|
+
Each entry is formatted as:
|
|
28
|
+
[<scope>] [Back-End|Front-End] <message>
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
chlog --type <added|changed|fixed|removed> --scope <ticket> --message "<text>" [--frontend]
|
|
32
|
+
chlog -t <added|changed|fixed|removed> -s <ticket> -m "<text>" [-f]
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
-t, --type <t> Entry type (required)
|
|
36
|
+
-s, --scope <ticket> Jira ticket or scope (required)
|
|
37
|
+
-m, --message "<msg>" Entry message (required)
|
|
38
|
+
-f, --frontend Use [Front-End] instead of [Back-End]
|
|
39
|
+
--tz "<iana>" Timezone (default: Asia/Manila)
|
|
40
|
+
-d, --dry-run Preview only
|
|
41
|
+
-h, --help Show help
|
|
42
|
+
`.trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function main() {
|
|
46
|
+
try {
|
|
47
|
+
const args = parseArgs(process.argv);
|
|
48
|
+
|
|
49
|
+
if (args.help) {
|
|
50
|
+
await renderHelp(getHelpText());
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
validateArgs(args);
|
|
55
|
+
|
|
56
|
+
const timestamp = generateTimestamp(new Date(), args.tz);
|
|
57
|
+
|
|
58
|
+
const entryDir = path.join(
|
|
59
|
+
process.cwd(),
|
|
60
|
+
"changelog",
|
|
61
|
+
"unreleased",
|
|
62
|
+
args.type,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const entryPath = path.join(entryDir, timestamp);
|
|
66
|
+
|
|
67
|
+
const content = buildLine({
|
|
68
|
+
scope: args.scope,
|
|
69
|
+
message: args.message,
|
|
70
|
+
frontend: args.frontend,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (args.dryRun) {
|
|
74
|
+
await renderDryRun({ entryPath, content });
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
await fs.mkdir(entryDir, { recursive: true });
|
|
79
|
+
|
|
80
|
+
await fs.writeFile(entryPath, content, {
|
|
81
|
+
encoding: "utf8",
|
|
82
|
+
flag: "wx",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await renderSuccess({ entryPath });
|
|
86
|
+
} catch (err) {
|
|
87
|
+
await renderError(err);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
main();
|
package/lib/chlog-lib.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const VALID_TYPES = new Set(["added", "changed", "fixed", "removed"]);
|
|
4
|
+
|
|
5
|
+
function parseArgs(argv) {
|
|
6
|
+
const args = {
|
|
7
|
+
type: null,
|
|
8
|
+
scope: null,
|
|
9
|
+
message: null,
|
|
10
|
+
tz: "Asia/Manila",
|
|
11
|
+
frontend: false,
|
|
12
|
+
dryRun: false,
|
|
13
|
+
help: false,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
for (let i = 2; i < argv.length; i++) {
|
|
17
|
+
const arg = argv[i];
|
|
18
|
+
|
|
19
|
+
if (arg === "-h" || arg === "--help") {
|
|
20
|
+
args.help = true;
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (arg === "--dry-run" || arg === "-d") {
|
|
25
|
+
args.dryRun = true;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (arg === "--frontend" || arg === "-f") {
|
|
30
|
+
args.frontend = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (arg === "--type" || arg === "-t") {
|
|
35
|
+
args.type = argv[++i];
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (arg === "--scope" || arg === "-s") {
|
|
40
|
+
args.scope = argv[++i];
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (arg === "--message" || arg === "-m") {
|
|
45
|
+
args.message = argv[++i];
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (arg === "--tz") {
|
|
50
|
+
args.tz = argv[++i];
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return args;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function validateArgs(args) {
|
|
61
|
+
if (!args.type || !VALID_TYPES.has(args.type)) {
|
|
62
|
+
throw new Error(`--type must be one of: added, changed, fixed, removed`);
|
|
63
|
+
}
|
|
64
|
+
if (!args.scope) {
|
|
65
|
+
throw new Error(`--scope is required (e.g. JES-33)`);
|
|
66
|
+
}
|
|
67
|
+
if (!args.message) {
|
|
68
|
+
throw new Error(`--message is required`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Validate timezone (throws RangeError if invalid)
|
|
72
|
+
new Intl.DateTimeFormat("en-US", { timeZone: args.tz }).format(new Date());
|
|
73
|
+
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function generateTimestamp(date = new Date(), timeZone = "Asia/Manila") {
|
|
78
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
79
|
+
timeZone,
|
|
80
|
+
year: "numeric",
|
|
81
|
+
month: "2-digit",
|
|
82
|
+
day: "2-digit",
|
|
83
|
+
hour: "2-digit",
|
|
84
|
+
minute: "2-digit",
|
|
85
|
+
second: "2-digit",
|
|
86
|
+
hour12: false,
|
|
87
|
+
}).formatToParts(date);
|
|
88
|
+
|
|
89
|
+
const map = Object.fromEntries(parts.map((p) => [p.type, p.value]));
|
|
90
|
+
|
|
91
|
+
return map.year + map.month + map.day + map.hour + map.minute + map.second;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildLine({ scope, message, frontend }) {
|
|
95
|
+
const layerLabel = frontend ? "[Front-End]" : "[Back-End]";
|
|
96
|
+
|
|
97
|
+
let msg = String(message ?? "").trim();
|
|
98
|
+
if (!msg.startsWith(layerLabel)) {
|
|
99
|
+
msg = `${layerLabel} ${msg}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return `[${scope}] ${msg}\n`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = {
|
|
106
|
+
VALID_TYPES,
|
|
107
|
+
parseArgs,
|
|
108
|
+
validateArgs,
|
|
109
|
+
generateTimestamp,
|
|
110
|
+
buildLine,
|
|
111
|
+
};
|
package/lib/chlog-ui.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const path = require("path");
|
|
4
|
+
|
|
5
|
+
function canUseInk(stream) {
|
|
6
|
+
return Boolean(stream?.isTTY) && process.env.TERM !== "dumb";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function printPlain(lines, { stream = process.stdout } = {}) {
|
|
10
|
+
const output = `${lines.join("\n")}\n`;
|
|
11
|
+
stream.write(output);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getRelativeEntryPath(entryPath) {
|
|
15
|
+
return path.relative(process.cwd(), entryPath);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function renderInkCard(
|
|
19
|
+
{ title, subtitle, lines, tone = "info" },
|
|
20
|
+
{ stream = process.stdout } = {},
|
|
21
|
+
) {
|
|
22
|
+
if (!canUseInk(stream)) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const ReactModule = await import("react");
|
|
28
|
+
const InkModule = await import("ink");
|
|
29
|
+
const React = ReactModule.default || ReactModule;
|
|
30
|
+
const { render, Box, Text, useApp } = InkModule;
|
|
31
|
+
|
|
32
|
+
const palette = {
|
|
33
|
+
info: "cyan",
|
|
34
|
+
success: "green",
|
|
35
|
+
warning: "yellow",
|
|
36
|
+
error: "red",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function ExitOnRender() {
|
|
40
|
+
const { exit } = useApp();
|
|
41
|
+
React.useEffect(() => {
|
|
42
|
+
exit();
|
|
43
|
+
}, [exit]);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function Card({ model }) {
|
|
48
|
+
const borderColor = palette[model.tone] || "cyan";
|
|
49
|
+
const textElements = model.lines.map((line, index) => {
|
|
50
|
+
const text = typeof line === "string" ? line : line.text;
|
|
51
|
+
const props = { key: `line-${index}` };
|
|
52
|
+
|
|
53
|
+
if (typeof line !== "string") {
|
|
54
|
+
if (line.color) {
|
|
55
|
+
props.color = line.color;
|
|
56
|
+
}
|
|
57
|
+
if (line.bold) {
|
|
58
|
+
props.bold = true;
|
|
59
|
+
}
|
|
60
|
+
if (line.dimColor) {
|
|
61
|
+
props.dimColor = true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return React.createElement(Text, props, text === "" ? " " : text);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return React.createElement(
|
|
69
|
+
Box,
|
|
70
|
+
{
|
|
71
|
+
flexDirection: "column",
|
|
72
|
+
alignSelf: "flex-start",
|
|
73
|
+
flexGrow: 0,
|
|
74
|
+
paddingX: 0,
|
|
75
|
+
paddingY: 0,
|
|
76
|
+
},
|
|
77
|
+
React.createElement(
|
|
78
|
+
Text,
|
|
79
|
+
{ color: borderColor, bold: true },
|
|
80
|
+
model.title,
|
|
81
|
+
),
|
|
82
|
+
model.subtitle
|
|
83
|
+
? React.createElement(Text, { dimColor: true }, model.subtitle)
|
|
84
|
+
: null,
|
|
85
|
+
model.lines.length
|
|
86
|
+
? React.createElement(
|
|
87
|
+
Box,
|
|
88
|
+
{ marginTop: 1, flexDirection: "column" },
|
|
89
|
+
textElements,
|
|
90
|
+
)
|
|
91
|
+
: null,
|
|
92
|
+
React.createElement(ExitOnRender, null),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const app = render(
|
|
97
|
+
React.createElement(Card, { model: { title, subtitle, lines, tone } }),
|
|
98
|
+
{
|
|
99
|
+
stdout: stream,
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
await app.waitUntilExit();
|
|
104
|
+
return true;
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function renderHelp(helpText) {
|
|
111
|
+
const lines = helpText.split("\n");
|
|
112
|
+
const styledLines = lines.map((line) => {
|
|
113
|
+
if (!line.trim()) {
|
|
114
|
+
return { text: line };
|
|
115
|
+
}
|
|
116
|
+
if (line === "Usage:" || line === "Options:") {
|
|
117
|
+
return { text: line, color: "cyan", bold: true };
|
|
118
|
+
}
|
|
119
|
+
if (line.startsWith(" chlog ")) {
|
|
120
|
+
return { text: line, color: "green" };
|
|
121
|
+
}
|
|
122
|
+
if (line.startsWith(" -")) {
|
|
123
|
+
return { text: line, color: "yellow" };
|
|
124
|
+
}
|
|
125
|
+
if (line.startsWith(" [<scope>]")) {
|
|
126
|
+
return { text: line, color: "magenta" };
|
|
127
|
+
}
|
|
128
|
+
if (line.startsWith(" changelog/")) {
|
|
129
|
+
return { text: line, color: "blue" };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { text: line, dimColor: true };
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
process.stdout.write("\n");
|
|
136
|
+
|
|
137
|
+
const rendered = await renderInkCard(
|
|
138
|
+
{
|
|
139
|
+
title: "PC360 Changelog CLI - v0.1.6",
|
|
140
|
+
subtitle: "Usage & options",
|
|
141
|
+
lines: styledLines,
|
|
142
|
+
tone: "info",
|
|
143
|
+
},
|
|
144
|
+
{ stream: process.stdout },
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
if (!rendered) {
|
|
148
|
+
printPlain(["", ...lines, ""], { stream: process.stdout });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
process.stdout.write("\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function renderDryRun({ entryPath, content }) {
|
|
156
|
+
const lines = [
|
|
157
|
+
`[Dry Run]`,
|
|
158
|
+
`Would create: ${entryPath}`,
|
|
159
|
+
"",
|
|
160
|
+
"--- Content ---",
|
|
161
|
+
"",
|
|
162
|
+
content.trimEnd(),
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
const rendered = await renderInkCard(
|
|
166
|
+
{
|
|
167
|
+
title: "Preview",
|
|
168
|
+
subtitle: "No files were written",
|
|
169
|
+
lines,
|
|
170
|
+
tone: "warning",
|
|
171
|
+
},
|
|
172
|
+
{ stream: process.stdout },
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
if (!rendered) {
|
|
176
|
+
printPlain(lines, { stream: process.stdout });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function renderSuccess({ entryPath }) {
|
|
181
|
+
const lines = [`Created: ${getRelativeEntryPath(entryPath)}`];
|
|
182
|
+
|
|
183
|
+
const rendered = await renderInkCard(
|
|
184
|
+
{
|
|
185
|
+
title: "Entry Created",
|
|
186
|
+
subtitle: "Changelog updated",
|
|
187
|
+
lines,
|
|
188
|
+
tone: "success",
|
|
189
|
+
},
|
|
190
|
+
{ stream: process.stdout },
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
if (!rendered) {
|
|
194
|
+
printPlain(lines, { stream: process.stdout });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function renderError(error) {
|
|
199
|
+
const lines = [`${error.message}`, "Run: chlog --help"];
|
|
200
|
+
process.stderr.write("\n");
|
|
201
|
+
|
|
202
|
+
const rendered = await renderInkCard(
|
|
203
|
+
{
|
|
204
|
+
title: "Error",
|
|
205
|
+
subtitle: "Could not create changelog entry",
|
|
206
|
+
lines,
|
|
207
|
+
tone: "error",
|
|
208
|
+
},
|
|
209
|
+
{ stream: process.stderr },
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
if (!rendered) {
|
|
213
|
+
printPlain(["", ...lines, ""], { stream: process.stderr });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
process.stderr.write("\n");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = {
|
|
221
|
+
renderHelp,
|
|
222
|
+
renderDryRun,
|
|
223
|
+
renderSuccess,
|
|
224
|
+
renderError,
|
|
225
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pc360/chlog",
|
|
3
|
+
"version": "0.1.6",
|
|
4
|
+
"description": "PC360 Changelog CLI Tool",
|
|
5
|
+
"author": "aalmazan@pcdsi.ph",
|
|
6
|
+
"bin": {
|
|
7
|
+
"chlog": "bin/chlog.js"
|
|
8
|
+
},
|
|
9
|
+
"license": "UNLICENSED",
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=18"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"ink": "^4.4.1",
|
|
15
|
+
"react": "^18.3.1"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const os = require("os");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const { spawnSync } = require("child_process");
|
|
9
|
+
|
|
10
|
+
const { generateTimestamp } = require("../lib/chlog-lib");
|
|
11
|
+
|
|
12
|
+
const BIN_PATH = path.resolve(__dirname, "../bin/chlog.js");
|
|
13
|
+
|
|
14
|
+
function createTempDir() {
|
|
15
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "chlog-cli-"));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function runCli(args, cwd) {
|
|
19
|
+
return spawnSync(process.execPath, [BIN_PATH, ...args], {
|
|
20
|
+
cwd,
|
|
21
|
+
encoding: "utf8",
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function cleanup(dir) {
|
|
26
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test("CLI dry-run previews target path/content and exits 0", () => {
|
|
30
|
+
const cwd = createTempDir();
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const res = runCli(
|
|
34
|
+
[
|
|
35
|
+
"--type",
|
|
36
|
+
"added",
|
|
37
|
+
"--scope",
|
|
38
|
+
"JES-99",
|
|
39
|
+
"--message",
|
|
40
|
+
"Test entry",
|
|
41
|
+
"--dry-run",
|
|
42
|
+
],
|
|
43
|
+
cwd,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
assert.equal(res.status, 0);
|
|
47
|
+
assert.match(res.stdout, /\[Dry Run\]/);
|
|
48
|
+
assert.match(
|
|
49
|
+
res.stdout,
|
|
50
|
+
/Would create: .*changelog[\/\\]unreleased[\/\\]added[\/\\]\d{14}/,
|
|
51
|
+
);
|
|
52
|
+
assert.match(res.stdout, /\[JES-99\] \[Back-End\] Test entry/);
|
|
53
|
+
assert.equal(fs.existsSync(path.join(cwd, "changelog")), false);
|
|
54
|
+
} finally {
|
|
55
|
+
cleanup(cwd);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("CLI supports short flags for required values and toggles", () => {
|
|
60
|
+
const cwd = createTempDir();
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const res = runCli(
|
|
64
|
+
["-t", "added", "-s", "JES-100", "-m", "Use short options", "-f", "-d"],
|
|
65
|
+
cwd,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
assert.equal(res.status, 0);
|
|
69
|
+
assert.match(res.stdout, /\[Dry Run\]/);
|
|
70
|
+
assert.match(
|
|
71
|
+
res.stdout,
|
|
72
|
+
/Would create: .*changelog[\/\\]unreleased[\/\\]added[\/\\]\d{14}/,
|
|
73
|
+
);
|
|
74
|
+
assert.match(res.stdout, /\[JES-100\] \[Front-End\] Use short options/);
|
|
75
|
+
assert.equal(fs.existsSync(path.join(cwd, "changelog")), false);
|
|
76
|
+
} finally {
|
|
77
|
+
cleanup(cwd);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("CLI creates changelog file with expected content and exits 0", () => {
|
|
82
|
+
const cwd = createTempDir();
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const res = runCli(
|
|
86
|
+
[
|
|
87
|
+
"--type",
|
|
88
|
+
"fixed",
|
|
89
|
+
"--scope",
|
|
90
|
+
"JES-45",
|
|
91
|
+
"--message",
|
|
92
|
+
"Fix pagination",
|
|
93
|
+
"--frontend",
|
|
94
|
+
],
|
|
95
|
+
cwd,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
assert.equal(res.status, 0);
|
|
99
|
+
assert.match(
|
|
100
|
+
res.stdout,
|
|
101
|
+
/Created: changelog[\/\\]unreleased[\/\\]fixed[\/\\]\d{14}/,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const entryDir = path.join(cwd, "changelog", "unreleased", "fixed");
|
|
105
|
+
const files = fs.readdirSync(entryDir);
|
|
106
|
+
assert.equal(files.length, 1);
|
|
107
|
+
assert.match(files[0], /^\d{14}$/);
|
|
108
|
+
|
|
109
|
+
const content = fs.readFileSync(path.join(entryDir, files[0]), "utf8");
|
|
110
|
+
assert.equal(content, "[JES-45] [Front-End] Fix pagination\n");
|
|
111
|
+
} finally {
|
|
112
|
+
cleanup(cwd);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("CLI returns exit code 1 for missing required flags", () => {
|
|
117
|
+
const cwd = createTempDir();
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const res = runCli(["--type", "added", "--scope", "JES-1"], cwd);
|
|
121
|
+
|
|
122
|
+
assert.equal(res.status, 1);
|
|
123
|
+
assert.match(res.stderr, /--message is required/);
|
|
124
|
+
assert.match(res.stderr, /Run: chlog --help/);
|
|
125
|
+
} finally {
|
|
126
|
+
cleanup(cwd);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("CLI surfaces timestamp collision errors and exits 1", () => {
|
|
131
|
+
const cwd = createTempDir();
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const entryDir = path.join(cwd, "changelog", "unreleased", "changed");
|
|
135
|
+
fs.mkdirSync(entryDir, { recursive: true });
|
|
136
|
+
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
for (let offset = -5; offset <= 5; offset++) {
|
|
139
|
+
const ts = generateTimestamp(new Date(now + offset * 1000), "Asia/Manila");
|
|
140
|
+
fs.writeFileSync(path.join(entryDir, ts), "existing\n", "utf8");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const res = runCli(
|
|
144
|
+
[
|
|
145
|
+
"--type",
|
|
146
|
+
"changed",
|
|
147
|
+
"--scope",
|
|
148
|
+
"JES-77",
|
|
149
|
+
"--message",
|
|
150
|
+
"Update report formatting",
|
|
151
|
+
],
|
|
152
|
+
cwd,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
assert.equal(res.status, 1);
|
|
156
|
+
assert.match(res.stderr, /EEXIST|file already exists/i);
|
|
157
|
+
assert.match(res.stderr, /Run: chlog --help/);
|
|
158
|
+
} finally {
|
|
159
|
+
cleanup(cwd);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
parseArgs,
|
|
8
|
+
validateArgs,
|
|
9
|
+
generateTimestamp,
|
|
10
|
+
buildLine,
|
|
11
|
+
} = require("../lib/chlog-lib");
|
|
12
|
+
|
|
13
|
+
test("parseArgs parses required flags and defaults", () => {
|
|
14
|
+
const args = parseArgs([
|
|
15
|
+
"node",
|
|
16
|
+
"chlog",
|
|
17
|
+
"--type",
|
|
18
|
+
"added",
|
|
19
|
+
"--scope",
|
|
20
|
+
"JES-33",
|
|
21
|
+
"--message",
|
|
22
|
+
"Hello",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
assert.equal(args.type, "added");
|
|
26
|
+
assert.equal(args.scope, "JES-33");
|
|
27
|
+
assert.equal(args.message, "Hello");
|
|
28
|
+
assert.equal(args.tz, "Asia/Manila");
|
|
29
|
+
assert.equal(args.frontend, false);
|
|
30
|
+
assert.equal(args.dryRun, false);
|
|
31
|
+
assert.equal(args.help, false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("parseArgs supports --frontend and --dry-run", () => {
|
|
35
|
+
const args = parseArgs([
|
|
36
|
+
"node",
|
|
37
|
+
"chlog",
|
|
38
|
+
"--type",
|
|
39
|
+
"fixed",
|
|
40
|
+
"--scope",
|
|
41
|
+
"JES-45",
|
|
42
|
+
"--message",
|
|
43
|
+
"Fix thing",
|
|
44
|
+
"--frontend",
|
|
45
|
+
"--dry-run",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
assert.equal(args.frontend, true);
|
|
49
|
+
assert.equal(args.dryRun, true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("parseArgs supports short flags", () => {
|
|
53
|
+
const args = parseArgs([
|
|
54
|
+
"node",
|
|
55
|
+
"chlog",
|
|
56
|
+
"-t",
|
|
57
|
+
"changed",
|
|
58
|
+
"-s",
|
|
59
|
+
"JES-50",
|
|
60
|
+
"-m",
|
|
61
|
+
"Update validation flow",
|
|
62
|
+
"-f",
|
|
63
|
+
"-d",
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
assert.equal(args.type, "changed");
|
|
67
|
+
assert.equal(args.scope, "JES-50");
|
|
68
|
+
assert.equal(args.message, "Update validation flow");
|
|
69
|
+
assert.equal(args.frontend, true);
|
|
70
|
+
assert.equal(args.dryRun, true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("parseArgs supports mixed long/short flags", () => {
|
|
74
|
+
const args = parseArgs([
|
|
75
|
+
"node",
|
|
76
|
+
"chlog",
|
|
77
|
+
"--type",
|
|
78
|
+
"fixed",
|
|
79
|
+
"-s",
|
|
80
|
+
"JES-88",
|
|
81
|
+
"--message",
|
|
82
|
+
"Mixed flags",
|
|
83
|
+
"-d",
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
assert.equal(args.type, "fixed");
|
|
87
|
+
assert.equal(args.scope, "JES-88");
|
|
88
|
+
assert.equal(args.message, "Mixed flags");
|
|
89
|
+
assert.equal(args.dryRun, true);
|
|
90
|
+
assert.equal(args.frontend, false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("parseArgs supports -h short help flag", () => {
|
|
94
|
+
const args = parseArgs(["node", "chlog", "-h"]);
|
|
95
|
+
assert.equal(args.help, true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("validateArgs throws if missing scope", () => {
|
|
99
|
+
assert.throws(
|
|
100
|
+
() =>
|
|
101
|
+
validateArgs({
|
|
102
|
+
type: "added",
|
|
103
|
+
scope: null,
|
|
104
|
+
message: "x",
|
|
105
|
+
tz: "Asia/Manila",
|
|
106
|
+
}),
|
|
107
|
+
/--scope is required/,
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("validateArgs throws if invalid type", () => {
|
|
112
|
+
assert.throws(
|
|
113
|
+
() =>
|
|
114
|
+
validateArgs({
|
|
115
|
+
type: "nope",
|
|
116
|
+
scope: "JES-1",
|
|
117
|
+
message: "x",
|
|
118
|
+
tz: "Asia/Manila",
|
|
119
|
+
}),
|
|
120
|
+
/--type must be one of/,
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("validateArgs throws if invalid timezone", () => {
|
|
125
|
+
assert.throws(
|
|
126
|
+
() =>
|
|
127
|
+
validateArgs({
|
|
128
|
+
type: "added",
|
|
129
|
+
scope: "JES-1",
|
|
130
|
+
message: "x",
|
|
131
|
+
tz: "Not/A_Timezone",
|
|
132
|
+
}),
|
|
133
|
+
/Invalid time zone|timeZone/i,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("generateTimestamp returns 14 digits", () => {
|
|
138
|
+
const ts = generateTimestamp(new Date("2026-02-13T00:00:00Z"), "Asia/Manila");
|
|
139
|
+
assert.match(ts, /^\d{14}$/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("buildLine defaults to [Back-End] prefix and includes scope", () => {
|
|
143
|
+
const line = buildLine({
|
|
144
|
+
scope: "JES-33",
|
|
145
|
+
message: "Add daily consolidated JE job",
|
|
146
|
+
frontend: false,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
assert.equal(line, "[JES-33] [Back-End] Add daily consolidated JE job\n");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("buildLine uses [Front-End] when frontend=true", () => {
|
|
153
|
+
const line = buildLine({
|
|
154
|
+
scope: "JES-45",
|
|
155
|
+
message: "Fix pagination",
|
|
156
|
+
frontend: true,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
assert.equal(line, "[JES-45] [Front-End] Fix pagination\n");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("buildLine does not double-prefix if message already starts with label", () => {
|
|
163
|
+
const line = buildLine({
|
|
164
|
+
scope: "JES-99",
|
|
165
|
+
message: "[Back-End] Already prefixed",
|
|
166
|
+
frontend: false,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
assert.equal(line, "[JES-99] [Back-End] Already prefixed\n");
|
|
170
|
+
});
|