@eclipse-docks/cli 0.7.114
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/README.md +26 -0
- package/bin.js +23 -0
- package/package.json +18 -0
- package/src/commands/release.js +162 -0
- package/src/index.js +5 -0
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @eclipse-docks/cli
|
|
2
|
+
|
|
3
|
+
Developer CLI for apps built on Eclipse Docks.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @eclipse-docks/cli release # bump patch, prompt for release notes
|
|
9
|
+
npx @eclipse-docks/cli release minor # bump minor
|
|
10
|
+
npx @eclipse-docks/cli release major -m "notes" # bump major with inline release notes
|
|
11
|
+
npx @eclipse-docks/cli release v1.2.3 # explicit version
|
|
12
|
+
npx @eclipse-docks/cli release --dry-run # print what would be committed
|
|
13
|
+
npx @eclipse-docks/cli release --no-push # commit locally without pushing
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## `release`
|
|
17
|
+
|
|
18
|
+
Creates an empty commit whose subject is the next version (`vX.Y.Z`) and pushes it.
|
|
19
|
+
Pair this with a CI workflow that tags/releases on that commit subject (see
|
|
20
|
+
[`.github/workflows/app-release.yml`](../../.github/workflows/app-release.yml) for a
|
|
21
|
+
reusable one) — the commit body becomes the release notes.
|
|
22
|
+
|
|
23
|
+
If no release notes are given via `-m`, an end-user-facing summary is generated from the
|
|
24
|
+
commits since the last version, grouped under **Features**, **Fixes**, **Docs**, and
|
|
25
|
+
**Chore/Refactor**, and offered as the default (press Enter on an empty first line to
|
|
26
|
+
accept it).
|
package/bin.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { commands } from './src/index.js';
|
|
3
|
+
|
|
4
|
+
async function main() {
|
|
5
|
+
const [name, ...rest] = process.argv.slice(2);
|
|
6
|
+
const command = name ? commands[name] : undefined;
|
|
7
|
+
|
|
8
|
+
if (!command) {
|
|
9
|
+
console.error(
|
|
10
|
+
`Usage: docks <command> [...args]\n\nCommands:\n${Object.keys(commands)
|
|
11
|
+
.map((c) => ` ${c}`)
|
|
12
|
+
.join('\n')}`,
|
|
13
|
+
);
|
|
14
|
+
process.exit(name ? 1 : 0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
await command(rest);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
main().catch((err) => {
|
|
21
|
+
console.error(err instanceof Error ? err.message : err);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@eclipse-docks/cli",
|
|
3
|
+
"version": "0.7.114",
|
|
4
|
+
"description": "Developer CLI for Eclipse Docks apps (release automation and more)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"docks": "./bin.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin.js",
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/eclipse-docks/core"
|
|
16
|
+
},
|
|
17
|
+
"license": "EPL-2.0"
|
|
18
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
|
|
4
|
+
const VERSION_RE = /^v\d+\.\d+\.\d+$/;
|
|
5
|
+
|
|
6
|
+
function git(args, cwd) {
|
|
7
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
8
|
+
if (result.status !== 0) {
|
|
9
|
+
throw new Error(`git ${args.join(' ')} failed: ${result.stderr.trim()}`);
|
|
10
|
+
}
|
|
11
|
+
return result.stdout.trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function gitOrNull(args, cwd) {
|
|
15
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
16
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseArgs(argv) {
|
|
20
|
+
const opts = { bump: 'patch', version: '', notes: '', dryRun: false, push: true };
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i < argv.length; i++) {
|
|
23
|
+
const arg = argv[i];
|
|
24
|
+
if (arg === 'major' || arg === 'minor' || arg === 'patch') {
|
|
25
|
+
opts.bump = arg;
|
|
26
|
+
} else if (VERSION_RE.test(arg)) {
|
|
27
|
+
opts.version = arg;
|
|
28
|
+
} else if (arg === '-m' || arg === '--message') {
|
|
29
|
+
opts.notes = argv[++i] ?? '';
|
|
30
|
+
} else if (arg === '-n' || arg === '--dry-run') {
|
|
31
|
+
opts.dryRun = true;
|
|
32
|
+
} else if (arg === '-p' || arg === '--push') {
|
|
33
|
+
opts.push = true;
|
|
34
|
+
} else if (arg === '--no-push') {
|
|
35
|
+
opts.push = false;
|
|
36
|
+
} else {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Unknown argument: ${arg}\nUsage: docks release [major|minor|patch|vX.Y.Z] [-m "release notes"] [--dry-run] [--no-push]`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return opts;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function findLastVersion(cwd) {
|
|
47
|
+
const log = gitOrNull(['log', '--pretty=%s'], cwd);
|
|
48
|
+
if (!log) return null;
|
|
49
|
+
for (const subject of log.split('\n')) {
|
|
50
|
+
if (VERSION_RE.test(subject)) return subject;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function bumpVersion(last, bump) {
|
|
56
|
+
const [major, minor, patch] = last.slice(1).split('.').map(Number);
|
|
57
|
+
switch (bump) {
|
|
58
|
+
case 'major':
|
|
59
|
+
return `v${major + 1}.0.0`;
|
|
60
|
+
case 'minor':
|
|
61
|
+
return `v${major}.${minor + 1}.0`;
|
|
62
|
+
default:
|
|
63
|
+
return `v${major}.${minor}.${patch + 1}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function generateSummary(cwd, last) {
|
|
68
|
+
// find the commit whose subject *is* the last version, not a tag (tags may not exist locally)
|
|
69
|
+
const lastCommit = last ? gitOrNull(['log', '-E', '--pretty=%H', '-1', `--grep=^${last}$`], cwd) : null;
|
|
70
|
+
const range = lastCommit ? `${lastCommit}..HEAD` : 'HEAD';
|
|
71
|
+
|
|
72
|
+
const log = gitOrNull(['log', range, '--no-merges', '--pretty=%s'], cwd) ?? '';
|
|
73
|
+
const buckets = { features: [], fixes: [], docs: [], chores: [] };
|
|
74
|
+
|
|
75
|
+
for (const subject of log.split('\n')) {
|
|
76
|
+
if (!subject || VERSION_RE.test(subject)) continue;
|
|
77
|
+
|
|
78
|
+
const stripped = subject.includes(': ') ? subject.slice(subject.indexOf(': ') + 2) : subject;
|
|
79
|
+
const text = stripped.charAt(0).toUpperCase() + stripped.slice(1);
|
|
80
|
+
|
|
81
|
+
if (/^(feat|perf)/.test(subject)) buckets.features.push(text);
|
|
82
|
+
else if (/^fix/.test(subject)) buckets.fixes.push(text);
|
|
83
|
+
else if (/^docs/.test(subject)) buckets.docs.push(text);
|
|
84
|
+
else if (/^(chore|ci|build|test|refactor|style)/.test(subject)) buckets.chores.push(text);
|
|
85
|
+
else buckets.features.push(text);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const sections = [];
|
|
89
|
+
const render = (title, items) => {
|
|
90
|
+
if (items.length) sections.push(`${title}\n${items.map((i) => `- ${i}`).join('\n')}`);
|
|
91
|
+
};
|
|
92
|
+
render('Features', buckets.features);
|
|
93
|
+
render('Fixes', buckets.fixes);
|
|
94
|
+
render('Docs', buckets.docs);
|
|
95
|
+
render('Chore/Refactor', buckets.chores);
|
|
96
|
+
|
|
97
|
+
return sections.join('\n\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function promptNotes(version, summary) {
|
|
101
|
+
console.log(`Enter release notes for ${version} (end with an empty line).`);
|
|
102
|
+
if (summary) {
|
|
103
|
+
console.log('Press Enter on an empty first line to use this auto-generated summary:');
|
|
104
|
+
console.log(summary.replace(/^/gm, ' '));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const rl = createInterface({ input: process.stdin });
|
|
108
|
+
const lines = [];
|
|
109
|
+
for await (const line of rl) {
|
|
110
|
+
if (line === '') break;
|
|
111
|
+
lines.push(line);
|
|
112
|
+
}
|
|
113
|
+
rl.close();
|
|
114
|
+
|
|
115
|
+
return lines.length ? lines.join('\n') + '\n' : summary ? summary + '\n' : '';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function release(argv, cwd = process.cwd()) {
|
|
119
|
+
const opts = parseArgs(argv);
|
|
120
|
+
|
|
121
|
+
if (!opts.dryRun && git(['status', '--porcelain'], cwd) !== '') {
|
|
122
|
+
throw new Error('Working tree is not clean. Commit or stash your changes first.');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const last = findLastVersion(cwd);
|
|
126
|
+
let version = opts.version;
|
|
127
|
+
if (!version) {
|
|
128
|
+
version = bumpVersion(last ?? 'v0.0.0', opts.bump);
|
|
129
|
+
console.log(`Last version commit: ${last ?? 'v0.0.0'}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (gitOrNull(['rev-parse', version], cwd)) {
|
|
133
|
+
throw new Error(`Tag ${version} already exists.`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let notes = opts.notes;
|
|
137
|
+
if (!notes) {
|
|
138
|
+
const summary = generateSummary(cwd, last);
|
|
139
|
+
notes = await promptNotes(version, summary);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (opts.dryRun) {
|
|
143
|
+
console.log(`\n[dry run] would create empty commit:\n ${version}\n`);
|
|
144
|
+
console.log(notes.replace(/^/gm, ' '));
|
|
145
|
+
if (opts.push) {
|
|
146
|
+
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
147
|
+
console.log(`[dry run] would push to origin ${branch}`);
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
git(['commit', '--allow-empty', '-m', version, '-m', notes], cwd);
|
|
153
|
+
|
|
154
|
+
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
155
|
+
if (opts.push) {
|
|
156
|
+
git(['push', 'origin', branch], cwd);
|
|
157
|
+
console.log(`\nPushed ${version} to origin/${branch}; the release workflow will take over.`);
|
|
158
|
+
} else {
|
|
159
|
+
console.log(`\nCreated release commit for ${version}. Push it to trigger the release:`);
|
|
160
|
+
console.log(` git push origin ${branch}`);
|
|
161
|
+
}
|
|
162
|
+
}
|