1 | #!/usr/bin/env node
|
2 | "use strict";
|
3 |
|
4 | const minOpts = {
|
5 | boolean: [
|
6 | "g", "gitless",
|
7 | "h", "help",
|
8 | "P", "packageless",
|
9 | "p", "prefix",
|
10 | "v", "version",
|
11 | ],
|
12 | string: [
|
13 | "b", "base",
|
14 | "c", "command",
|
15 | "d", "date",
|
16 | "r", "replace",
|
17 | "_",
|
18 | ],
|
19 | alias: {
|
20 | b: "base",
|
21 | c: "command",
|
22 | d: "date",
|
23 | g: "gitless",
|
24 | h: "help",
|
25 | P: "packageless",
|
26 | p: "prefix",
|
27 | r: "replace",
|
28 | v: "version",
|
29 | }
|
30 | };
|
31 |
|
32 | const commands = ["patch", "minor", "major"];
|
33 | let args = require("minimist")(process.argv.slice(2), minOpts);
|
34 | args = fixArgs(commands, args, minOpts);
|
35 | let [level, ...files] = args._;
|
36 |
|
37 | if (args.version) {
|
38 | console.info(require(require("path").join(__dirname, "package.json")).version);
|
39 | process.exit(0);
|
40 | }
|
41 |
|
42 | if (!commands.includes(level) || args.help) {
|
43 | console.info(`usage: ver [options] command [files...]
|
44 |
|
45 | Semantically increment a project's version in multiple files.
|
46 |
|
47 | Commands:
|
48 | patch Increment patch 0.0.x version
|
49 | minor Increment minor 0.x.0 version
|
50 | major Increment major x.0.0 version
|
51 |
|
52 | Arguments:
|
53 | files Files to do version replacement in. The nearest package.json and
|
54 | package-lock.json will always be included unless the -P argument is given.
|
55 | Options:
|
56 | -b, --base <version> Base version to use. Default is parsed from the nearest package.json
|
57 | -c, --command <command> Run a command after files are updated but before git commit and tag
|
58 | -d, --date [<date>] Replace dates in format YYYY-MM-DD with current or given date
|
59 | -r, --replace <str> Additional replacement in the format "s#regexp#replacement#flags"
|
60 | -P, --packageless Do not include package.json and package-lock.json unless explicitely given
|
61 | -g, --gitless Do not create a git commit and tag
|
62 | -p, --prefix Prefix git tags with a "v" character
|
63 | -v, --version Print the version
|
64 | -h, --help Print this help
|
65 |
|
66 | Examples:
|
67 | $ ver patch
|
68 | $ ver minor build.js
|
69 | $ ver major -p build.js
|
70 | $ ver patch -c 'npm run build'`);
|
71 | exit();
|
72 | }
|
73 |
|
74 | const replacements = [];
|
75 | if (args.replace) {
|
76 | args.replace = Array.isArray(args.replace) ? args.replace : [args.replace];
|
77 | for (const replaceStr of args.replace) {
|
78 | let [_, re, replacement, flags] = (/^s#(.+?)#(.+?)#(.*?)$/.exec(replaceStr) || []);
|
79 |
|
80 | if (!re || !replacement) {
|
81 | exit(new Error(`Invalid replace string: ${replaceStr}`));
|
82 | }
|
83 |
|
84 | re = new RegExp(re, flags || undefined);
|
85 | replacements.push({re, replacement});
|
86 | }
|
87 | }
|
88 |
|
89 | let date = parseMixedArg(args.date);
|
90 | if (date) {
|
91 | if (date === true) {
|
92 | date = (new Date()).toISOString().substring(0, 10);
|
93 | } else if (Array.isArray(date)) {
|
94 | date = date[date.length - 1];
|
95 | }
|
96 |
|
97 | if (typeof date !== "string" || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(date)) {
|
98 | exit(`Invalid date argument: ${date}`);
|
99 | }
|
100 | }
|
101 |
|
102 | const {promisify} = require("util");
|
103 | const readFile = promisify(require("fs").readFile);
|
104 | const writeFile = promisify(require("fs").writeFile);
|
105 | const truncate = promisify(require("fs").truncate);
|
106 | const stat = promisify(require("fs").stat);
|
107 | const realpath = promisify(require("fs").realpath);
|
108 | const semver = require("semver");
|
109 | const {basename} = require("path");
|
110 | const findUp = require("find-up");
|
111 |
|
112 | async function main() {
|
113 | let packageFile = await findUp("package.json");
|
114 | if (packageFile) packageFile = await realpath(packageFile);
|
115 |
|
116 |
|
117 | let pkg, pkgStr;
|
118 | if (packageFile) {
|
119 | try {
|
120 | pkgStr = await readFile(packageFile, "utf8");
|
121 | pkg = JSON.parse(pkgStr);
|
122 | } catch (err) {
|
123 | throw new Error(`Error reading ${packageFile}: ${err.message}`);
|
124 | }
|
125 | }
|
126 |
|
127 |
|
128 | let baseVersion;
|
129 | if (!args.base) {
|
130 | if (pkg) {
|
131 | if (pkg.version) {
|
132 | baseVersion = pkg.version;
|
133 | } else {
|
134 | throw new Error(`No "version" field found in ${packageFile}`);
|
135 | }
|
136 | } else {
|
137 | throw new Error(`Unable to obtain base version, either create package.json or specify --base`);
|
138 | }
|
139 | } else {
|
140 | baseVersion = args.base;
|
141 | }
|
142 |
|
143 |
|
144 | if (!semver.valid(baseVersion)) {
|
145 | throw new Error(`Invalid base version: ${baseVersion}`);
|
146 | }
|
147 |
|
148 |
|
149 |
|
150 | files = await require("fast-glob")(files);
|
151 |
|
152 |
|
153 | files = await Promise.all(files.map(file => realpath(file)));
|
154 |
|
155 |
|
156 | files = Array.from(new Set(files));
|
157 |
|
158 | if (!args.packageless) {
|
159 |
|
160 | if (packageFile && !files.includes(packageFile)) {
|
161 | files.push(packageFile);
|
162 | }
|
163 |
|
164 |
|
165 | let packageLockFile = await findUp("package-lock.json");
|
166 | if (packageLockFile) packageLockFile = await realpath(packageLockFile);
|
167 | if (packageLockFile && !files.includes(packageLockFile)) {
|
168 | files.push(packageLockFile);
|
169 | }
|
170 | }
|
171 |
|
172 | if (!files.length) {
|
173 | throw new Error(`Found no files to do replacements in`);
|
174 | }
|
175 |
|
176 |
|
177 | for (const file of files) {
|
178 | const stats = await stat(file);
|
179 | if (!stats.isFile() && !stats.isSymbolicLink()) {
|
180 | throw new Error(`${file} is not a file`);
|
181 | }
|
182 | }
|
183 |
|
184 |
|
185 | const newVersion = semver.inc(baseVersion, level);
|
186 | for (const file of files) {
|
187 | if (basename(file) === "package.json") {
|
188 | await updateFile({file, baseVersion, newVersion, replacements, pkgStr});
|
189 | } else {
|
190 | await updateFile({file, baseVersion, newVersion, replacements});
|
191 | }
|
192 | }
|
193 |
|
194 | if (args.command) {
|
195 | await run(args.command);
|
196 | }
|
197 |
|
198 | if (!args["gitless"]) {
|
199 |
|
200 | const tagName = args["prefix"] ? `v${newVersion}` : newVersion;
|
201 | try {
|
202 | await run(`git commit -a -m ${newVersion}`);
|
203 | await run(`git tag -f -m ${newVersion} ${tagName}`);
|
204 | } catch (err) {
|
205 | return process.exit(1);
|
206 | }
|
207 | }
|
208 |
|
209 | exit();
|
210 | }
|
211 |
|
212 | async function run(cmd) {
|
213 | console.info(`+ ${cmd}`);
|
214 | const child = require("execa")(cmd, {shell: true});
|
215 | child.stdout.pipe(process.stdout);
|
216 | child.stderr.pipe(process.stderr);
|
217 | await child;
|
218 | }
|
219 |
|
220 | async function updateFile({file, baseVersion, newVersion, replacements, pkgStr}) {
|
221 | let oldData;
|
222 | if (pkgStr) {
|
223 | oldData = pkgStr;
|
224 | } else {
|
225 | oldData = await readFile(file, "utf8");
|
226 | }
|
227 |
|
228 | let newData;
|
229 | if (pkgStr) {
|
230 | const re = new RegExp(`("version":[^]*?")${esc(baseVersion)}(")`);
|
231 | newData = pkgStr.replace(re, (_, p1, p2) => `${p1}${newVersion}${p2}`);
|
232 | } else if (basename(file) === "package-lock.json") {
|
233 |
|
234 |
|
235 |
|
236 | newData = JSON.parse(oldData);
|
237 | newData.version = newVersion;
|
238 | newData = JSON.stringify(newData, null, 2) + "\n";
|
239 | } else {
|
240 | const re = new RegExp(esc(baseVersion), "g");
|
241 | newData = oldData.replace(re, newVersion);
|
242 | }
|
243 |
|
244 | if (date) {
|
245 | const re = new RegExp(`([^0-9]|^)[0-9]{4}-[0-9]{2}-[0-9]{2}([^0-9]|$)`, "g");
|
246 | newData = newData.replace(re, (_, p1, p2) => `${p1}${date}${p2}`);
|
247 | }
|
248 |
|
249 | if (replacements.length) {
|
250 | for (const replacement of replacements) {
|
251 | newData = newData.replace(replacement.re, replacement.replacement);
|
252 | }
|
253 | }
|
254 |
|
255 | if (oldData === newData) {
|
256 | throw new Error(`No replacement made in ${file}`);
|
257 | } else {
|
258 | await write(file, newData);
|
259 | }
|
260 | }
|
261 |
|
262 | async function write(file, content) {
|
263 | if (require("os").platform() === "win32") {
|
264 |
|
265 | await truncate(file, 0);
|
266 | await writeFile(file, content, {encoding: "utf8", flag: "r+"});
|
267 | } else {
|
268 | await writeFile(file, content, {encoding: "utf8"});
|
269 | }
|
270 | }
|
271 |
|
272 | function parseMixedArg(arg) {
|
273 | if (arg === "") {
|
274 | return true;
|
275 | } else if (typeof arg === "string") {
|
276 | return arg.includes(",") ? arg.split(",") : [arg];
|
277 | } else if (Array.isArray(arg)) {
|
278 | return arg;
|
279 | } else {
|
280 | return Boolean(arg);
|
281 | }
|
282 | }
|
283 |
|
284 |
|
285 | function fixArgs(commands, args, minOpts) {
|
286 | for (const key of Object.keys(minOpts.alias)) {
|
287 | delete args[key];
|
288 | }
|
289 |
|
290 | if (commands.includes(args.date)) {
|
291 | args._ = [args.date, ...args._];
|
292 | args.date = true;
|
293 | }
|
294 | if (commands.includes(args.base)) {
|
295 | args._ = [args.base, ...args._];
|
296 | args.base = true;
|
297 | }
|
298 | if (commands.includes(args.command)) {
|
299 | args._ = [args.command, ...args._];
|
300 | args.command = "";
|
301 | }
|
302 | if (commands.includes(args.replace)) {
|
303 | args._ = [args.replace, ...args._];
|
304 | args.replace = "";
|
305 | }
|
306 | if (commands.includes(args.packageless)) {
|
307 | args._ = [args.packageless, ...args._];
|
308 | args.packageless = true;
|
309 | }
|
310 |
|
311 | return args;
|
312 | }
|
313 |
|
314 | function esc(str) {
|
315 | return str.replace(/[|\\{}()[\]^$+*?.-]/g, "\\$&");
|
316 | }
|
317 |
|
318 | function exit(err) {
|
319 | if (err) {
|
320 | console.info(String(err.message || err).trim());
|
321 | }
|
322 | process.exit(err ? 1 : 0);
|
323 | }
|
324 |
|
325 | main().then(exit).catch(exit);
|