@edifice.io/cli 1.6.0-develop.7 → 1.6.0-develop.8

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/bin/index.js CHANGED
@@ -1,22 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { program } from 'commander';
4
- import interpret from 'interpret';
5
- import Liftoff from 'liftoff';
6
- import minimist from 'minimist';
7
- import { readFileSync } from 'node:fs';
8
- import { createRequire } from 'node:module';
9
- import { dirname, join } from 'node:path';
10
- import { fileURLToPath, pathToFileURL } from 'node:url';
11
- import v8flags from 'v8flags';
12
- import { publish } from '../scripts/publish.js';
3
+ import { program } from "commander";
4
+ import interpret from "interpret";
5
+ import Liftoff from "liftoff";
6
+ import minimist from "minimist";
7
+ import { readFileSync } from "node:fs";
8
+ import { createRequire } from "node:module";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
+ import v8flags from "v8flags";
12
+ import { publish } from "../src/publish/index.js";
13
13
 
14
14
  const args = process.argv.slice(2);
15
15
  const argv = minimist(args);
16
16
 
17
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
18
  const pkg = JSON.parse(
19
- readFileSync(join(__dirname, '../package.json'), 'utf8')
19
+ readFileSync(join(__dirname, "../package.json"), "utf8"),
20
20
  );
21
21
 
22
22
  const require = createRequire(import.meta.url);
@@ -30,7 +30,7 @@ async function requireOrImport(path) {
30
30
  return require(path);
31
31
  } catch (e) {
32
32
  // @ts-expect-error
33
- if (e.code === 'ERR_REQUIRE_ESM') {
33
+ if (e.code === "ERR_REQUIRE_ESM") {
34
34
  // This is needed on Windows, because import() fails if providing a Windows file path.
35
35
  const url = pathToFileURL(path);
36
36
  // @ts-expect-error
@@ -41,11 +41,11 @@ async function requireOrImport(path) {
41
41
  }
42
42
 
43
43
  const Config = new Liftoff({
44
- name: 'edifice-config',
45
- configName: 'edifice.config',
44
+ name: "edifice-config",
45
+ configName: "edifice.config",
46
46
  // @ts-expect-error
47
47
  extensions: interpret.jsVariants,
48
- preload: 'esbuild-register/dist/node',
48
+ preload: "esbuild-register/dist/node",
49
49
  v8flags,
50
50
  });
51
51
 
@@ -56,11 +56,11 @@ function checkForConfigFile(configPath) {
56
56
  if (configPath) return;
57
57
  console.error(
58
58
  [
59
- 'No edifice.config.js file found!',
59
+ "No edifice.config.js file found!",
60
60
  "This may be because you're not passing the --config or --cwd flags.",
61
- 'If you are passing these flags, check that the path is correct.',
62
- 'Otherwise, you can create a `edifice.config.js` file in your project root.',
63
- ].join('\n')
61
+ "If you are passing these flags, check that the path is correct.",
62
+ "Otherwise, you can create a `edifice.config.js` file in your project root.",
63
+ ].join("\n"),
64
64
  );
65
65
  process.exit(1);
66
66
  }
@@ -76,30 +76,30 @@ Config.prepare(
76
76
  requireOrImport(env.configPath)
77
77
  .then((configOpts) => {
78
78
  program
79
- .name('@edifice.io/cli')
79
+ .name("@edifice.io/cli")
80
80
  .description(
81
- 'Configuration and tools for publishing and maintaining packages'
81
+ "Configuration and tools for publishing and maintaining packages",
82
82
  )
83
83
  .version(pkg.version);
84
84
 
85
85
  if (configOpts) {
86
86
  for (const key of Object.keys(configOpts)) {
87
- program.setOptionValueWithSource(key, configOpts[key], 'config');
87
+ program.setOptionValueWithSource(key, configOpts[key], "config");
88
88
  }
89
89
  }
90
90
 
91
91
  program
92
- .command('publish')
92
+ .command("publish")
93
93
  .description(
94
- 'Publish your package with the current working directory'
94
+ "Publish your package with the current working directory",
95
95
  )
96
96
  .option(
97
- '--cwd <dir>',
98
- 'Current working directory of the configuration file'
97
+ "--cwd <dir>",
98
+ "Current working directory of the configuration file",
99
99
  )
100
- .option('--config <config>', 'The path to the configuration file')
101
- .option('--tag <tag>', 'The tag to publish to')
102
- .option('--branch <branch>', 'The branch to publish from')
100
+ .option("--config <config>", "The path to the configuration file")
101
+ .option("--tag <tag>", "The tag to publish to")
102
+ .option("--branch <branch>", "The branch to publish from")
103
103
  .action((_str, opts) => {
104
104
  return publish({
105
105
  branchConfigs: configOpts.publishOptions.branchConfigs,
@@ -121,5 +121,5 @@ Config.prepare(
121
121
  process.exit(1);
122
122
  });
123
123
  });
124
- }
124
+ },
125
125
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edifice.io/cli",
3
- "version": "1.6.0-develop.7",
3
+ "version": "1.6.0-develop.8",
4
4
  "description": "Edifice Frontend CLI",
5
5
  "keywords": [
6
6
  "frontend",
@@ -30,7 +30,8 @@
30
30
  "edifice-cli": "./bin/index.js"
31
31
  },
32
32
  "files": [
33
- "bin"
33
+ "bin",
34
+ "src"
34
35
  ],
35
36
  "dependencies": {
36
37
  "@commitlint/parse": "^19.5.0",
@@ -0,0 +1,37 @@
1
+ export type Commit = {
2
+ hash: string;
3
+ body: string;
4
+ subject: string;
5
+ author_name: string;
6
+ author_email: string;
7
+ type: string;
8
+ scope: string | null | undefined;
9
+ };
10
+
11
+ export type Package = {
12
+ name: string;
13
+ packageDir: string;
14
+ };
15
+
16
+ export type BranchConfig = {
17
+ prerelease: boolean;
18
+ previousVersion?: boolean;
19
+ };
20
+
21
+ export type Options = {
22
+ /** Contains config for publishable branches. */
23
+ branchConfigs: Record<string, BranchConfig>;
24
+ /** List your npm packages here. The first package will be used as the versioner. */
25
+ packages: Array<Package>;
26
+ /** Path to root directory of your project. */
27
+ rootDir: string;
28
+ /** The branch to publish. Defaults to the current branch if none supplied. */
29
+ branch?: string;
30
+ /** A manual tag to force release. Must start with `v` */
31
+ tag?: string;
32
+ /** The GitHub token used to search for user metadata and make a GitHub release. */
33
+ ghToken?: string;
34
+ emptyPackageScript?: boolean;
35
+ };
36
+
37
+ export function publish(options: Options): Promise<void>;
@@ -0,0 +1,438 @@
1
+ // @ts-check
2
+ // Originally ported to TS from https://github.com/remix-run/react-router/tree/main/scripts/{version,publish}.js
3
+
4
+ import { parse as parseCommit } from "@commitlint/parse";
5
+ import currentGitBranch from "current-git-branch";
6
+ import { execSync } from "node:child_process";
7
+ import path from "node:path";
8
+ import * as semver from "semver";
9
+ import { simpleGit } from "simple-git";
10
+ import {
11
+ capitalize,
12
+ getSorterFn,
13
+ readPackageJson,
14
+ releaseCommitMsg,
15
+ updatePackageJson,
16
+ } from "./utilities.js";
17
+
18
+ /**
19
+ * Execute a script being published
20
+ * @param {import('./index').Options} options
21
+ * @returns {Promise<void>}
22
+ */
23
+ export const publish = async (options) => {
24
+ const { branchConfigs, packages, rootDir, branch, tag, ghToken } = options;
25
+
26
+ const branchName = /** @type {string} */ (branch ?? currentGitBranch());
27
+ const isMainBranch = branchName === "main";
28
+ const npmTag = isMainBranch ? "latest" : branchName;
29
+
30
+ /** @type {import('./index').BranchConfig | undefined} */
31
+ const branchConfig = branchConfigs[branchName];
32
+
33
+ if (!branchConfig) {
34
+ throw new Error(`No publish config found for branch: ${branchName}`);
35
+ }
36
+
37
+ // Get tags
38
+ /** @type {string[]} */
39
+ const allTags = execSync("git tag").toString().split("\n");
40
+
41
+ const filteredTags = allTags
42
+ // Ensure tag is valid
43
+ .filter((t) => semver.valid(t))
44
+ // Filter tags based on whether the branch is a release or pre-release
45
+ .filter((t) => {
46
+ const isPrereleaseTag = semver.prerelease(t) !== null;
47
+ return branchConfig.prerelease ? isPrereleaseTag : !isPrereleaseTag;
48
+ })
49
+ // sort by latest
50
+ // @ts-ignore
51
+ .sort(semver.compare);
52
+
53
+ // Get the latest tag
54
+ let latestTag = filteredTags.at(-1);
55
+ let rangeFrom = latestTag;
56
+
57
+ // If RELEASE_ALL is set via a commit subject or body, all packages will be
58
+ // released regardless if they have changed files matching the package srcDir.
59
+ let RELEASE_ALL = false;
60
+
61
+ // Validate manual tag
62
+ if (tag) {
63
+ if (!semver.valid(tag)) {
64
+ throw new Error(`tag '${tag}' is not a semantically valid version`);
65
+ }
66
+ if (!tag.startsWith("v")) {
67
+ throw new Error(
68
+ `tag must start with "v" (e.g. v0.0.0). You supplied ${tag}`,
69
+ );
70
+ }
71
+ if (allTags.includes(tag)) {
72
+ throw new Error(`tag ${tag} has already been released`);
73
+ }
74
+ }
75
+
76
+ if (!latestTag || tag) {
77
+ if (tag) {
78
+ console.info(
79
+ `Tag is set to ${tag}. This will force release all packages. Publishing...`,
80
+ );
81
+ RELEASE_ALL = true;
82
+
83
+ // Is it the first release? Is it a major version?
84
+ if (!latestTag || (semver.patch(tag) === 0 && semver.minor(tag) === 0)) {
85
+ rangeFrom = "origin/main";
86
+ latestTag = tag;
87
+ }
88
+ } else {
89
+ throw new Error(
90
+ "Could not find latest tag! To make a release tag of v0.0.1, run with TAG=v0.0.1",
91
+ );
92
+ }
93
+ }
94
+
95
+ console.info(`Git Range: ${rangeFrom}..HEAD`);
96
+
97
+ const rawCommitsLog = (
98
+ await simpleGit().log({ from: rangeFrom, to: "HEAD" })
99
+ ).all.filter((c) => {
100
+ const exclude = [
101
+ c.message.startsWith("Merge branch "), // No merge commits
102
+ c.message.startsWith(releaseCommitMsg("")), // No example update commits
103
+ ].some(Boolean);
104
+
105
+ return !exclude;
106
+ });
107
+
108
+ // /**
109
+ // * Get the commits since the latest tag
110
+ // * @type {import('./index.js').Commit[]}
111
+ // */
112
+ const commitsSinceLatestTag = await Promise.all(
113
+ rawCommitsLog.map(async (c) => {
114
+ const parsed = await parseCommit(c.message);
115
+ return {
116
+ hash: c.hash.substring(0, 7),
117
+ body: c.body,
118
+ subject: parsed.subject ?? "",
119
+ author_name: c.author_name,
120
+ author_email: c.author_email,
121
+ type: parsed.type?.toLowerCase() ?? "other",
122
+ scope: parsed.scope,
123
+ };
124
+ }),
125
+ );
126
+
127
+ console.info(
128
+ `Parsing ${commitsSinceLatestTag.length} commits since ${rangeFrom}...`,
129
+ );
130
+
131
+ /**
132
+ * Parses the commit messages, logs them, and determines the type of release needed
133
+ * -1 means no release is necessary
134
+ * 0 means patch release is necessary
135
+ * 1 means minor release is necessary
136
+ * 2 means major release is necessary
137
+ * @type {number}
138
+ */
139
+ let recommendedReleaseLevel = commitsSinceLatestTag.reduce(
140
+ (releaseLevel, commit) => {
141
+ if (commit.type) {
142
+ if (["fix", "refactor", "perf"].includes(commit.type)) {
143
+ releaseLevel = Math.max(releaseLevel, 0);
144
+ }
145
+ if (["feat"].includes(commit.type)) {
146
+ releaseLevel = Math.max(releaseLevel, 1);
147
+ }
148
+ if (commit.body.includes("BREAKING CHANGE")) {
149
+ releaseLevel = Math.max(releaseLevel, 2);
150
+ }
151
+ if (
152
+ commit.subject.includes("RELEASE_ALL") ||
153
+ commit.body.includes("RELEASE_ALL")
154
+ ) {
155
+ RELEASE_ALL = true;
156
+ }
157
+ }
158
+
159
+ return releaseLevel;
160
+ },
161
+ -1,
162
+ );
163
+
164
+ // If there is a breaking change and no manual tag is set, do not release
165
+ /* if (recommendedReleaseLevel === 2 && !tag) {
166
+ throw new Error(
167
+ 'Major versions releases must be tagged and released manually.'
168
+ );
169
+ } */
170
+
171
+ // If no release is semantically necessary and no manual tag is set, do not release
172
+ if (recommendedReleaseLevel === -1 && !tag) {
173
+ console.info(
174
+ `There have been no changes since ${latestTag} that require a new version. You're good!`,
175
+ );
176
+ return;
177
+ }
178
+
179
+ // If no release is semantically necessary but a manual tag is set, do a patch release
180
+ if (recommendedReleaseLevel === -1 && tag) {
181
+ recommendedReleaseLevel = 0;
182
+ }
183
+
184
+ const releaseType = branchConfig.prerelease
185
+ ? "prerelease"
186
+ : /** @type {const} */ ({ 0: "patch", 1: "minor", 2: "major" })[
187
+ recommendedReleaseLevel
188
+ ];
189
+
190
+ if (!releaseType) {
191
+ throw new Error(`Invalid release level: ${recommendedReleaseLevel}`);
192
+ }
193
+
194
+ const version = tag
195
+ ? semver.parse(tag)?.version
196
+ : semver.inc(latestTag, releaseType, npmTag);
197
+
198
+ if (!version) {
199
+ throw new Error(
200
+ [
201
+ "Invalid version increment from semver.inc()",
202
+ `- latestTag: ${latestTag}`,
203
+ `- recommendedReleaseLevel: ${recommendedReleaseLevel}`,
204
+ `- prerelease: ${branchConfig.prerelease}`,
205
+ ].join("\n"),
206
+ );
207
+ }
208
+
209
+ console.log(`Targeting version ${version}...`);
210
+
211
+ /**
212
+ * Uses git diff to determine which files have changed since the latest tag
213
+ * @type {string[]}
214
+ */
215
+ const changedFiles = tag
216
+ ? []
217
+ : execSync(`git diff ${latestTag} --name-only`)
218
+ .toString()
219
+ .split("\n")
220
+ .filter(Boolean);
221
+
222
+ /** Uses packages and changedFiles to determine which packages have changed */
223
+ const changedPackages = RELEASE_ALL
224
+ ? packages
225
+ : packages.filter((pkg) => {
226
+ const changed = changedFiles.some(
227
+ (file) =>
228
+ file.startsWith(path.join(pkg.packageDir, "src")) ||
229
+ file.startsWith(path.join(pkg.packageDir, "package.json")),
230
+ );
231
+ return changed;
232
+ });
233
+
234
+ // If a package has a dependency that has been updated, we need to update the
235
+ // package that depends on it as well.
236
+ // run this multiple times so that dependencies of dependencies are also included
237
+ for (let runs = 0; runs < 3; runs++) {
238
+ for (const pkg of packages) {
239
+ const packageJson = await readPackageJson(
240
+ path.resolve(rootDir, pkg.packageDir, "package.json"),
241
+ );
242
+ const allDependencies = Object.keys(
243
+ Object.assign(
244
+ {},
245
+ packageJson.dependencies ?? {},
246
+ packageJson.peerDependencies ?? {},
247
+ ),
248
+ );
249
+
250
+ if (
251
+ allDependencies.find((dep) =>
252
+ changedPackages.find((d) => d.name === dep),
253
+ ) &&
254
+ !changedPackages.find((d) => d.name === pkg.name)
255
+ ) {
256
+ console.info(` Adding dependency ${pkg.name} to changed packages`);
257
+ changedPackages.push(pkg);
258
+ }
259
+ }
260
+ }
261
+
262
+ const changelogCommitsMd = await Promise.all(
263
+ Object.entries(
264
+ commitsSinceLatestTag.reduce((prev, curr) => {
265
+ return {
266
+ ...prev,
267
+ [curr.type]: [...(prev[curr.type] ?? []), curr],
268
+ };
269
+ }, /** @type {Record<string, import('./index').Commit[]>} */ ({})),
270
+ )
271
+ .sort(
272
+ getSorterFn(([type]) =>
273
+ [
274
+ "other",
275
+ "examples",
276
+ "docs",
277
+ "ci",
278
+ "test",
279
+ "chore",
280
+ "refactor",
281
+ "perf",
282
+ "fix",
283
+ "feat",
284
+ ].indexOf(type),
285
+ ),
286
+ )
287
+ .reverse()
288
+ .map(async ([type, commits]) => {
289
+ return Promise.all(
290
+ commits.map(async (commit) => {
291
+ let username = "";
292
+
293
+ if (ghToken) {
294
+ const query = commit.author_email;
295
+
296
+ const res = await fetch(
297
+ `https://api.github.com/search/users?q=${query}`,
298
+ {
299
+ headers: {
300
+ Authorization: `token ${ghToken}`,
301
+ },
302
+ },
303
+ );
304
+ const data = /** @type {unknown} */ (await res.json());
305
+ if (data && typeof data === "object" && "items" in data) {
306
+ if (Array.isArray(data.items) && data.items[0]) {
307
+ const item = /** @type {object} */ (data.items[0]);
308
+ if ("login" in item && typeof item.login === "string") {
309
+ username = item.login;
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ const scope = commit.scope ? `${commit.scope}: ` : "";
316
+ const subject = commit.subject;
317
+
318
+ return `- ${scope}${subject} (${commit.hash}) ${
319
+ username
320
+ ? `by @${username}`
321
+ : `by ${commit.author_name || commit.author_email}`
322
+ }`;
323
+ }),
324
+ ).then((c) => /** @type {const} */ ([type, c]));
325
+ }),
326
+ ).then((groups) => {
327
+ return groups
328
+ .map(([type, commits]) => {
329
+ return [`### ${capitalize(type)}`, commits.join("\n")].join("\n\n");
330
+ })
331
+ .join("\n\n");
332
+ });
333
+
334
+ const date = new Intl.DateTimeFormat(undefined, {
335
+ dateStyle: "short",
336
+ timeStyle: "short",
337
+ }).format(Date.now());
338
+
339
+ const changelogMd = [
340
+ `Version ${version} - ${date}${tag ? " (Manual Release)" : ""}`,
341
+ "## Changes",
342
+ changelogCommitsMd || "- None",
343
+ "## Packages",
344
+ changedPackages.map((d) => `- ${d.name}@${version}`).join("\n"),
345
+ ].join("\n\n");
346
+
347
+ console.info("Generating changelog...");
348
+ console.info();
349
+ console.info(changelogMd);
350
+ console.info();
351
+
352
+ if (changedPackages.length === 0) {
353
+ console.info("No packages have been affected.");
354
+ return;
355
+ }
356
+
357
+ if (!process.env.CI) {
358
+ console.warn(
359
+ `This is a dry run for version ${version}. Push to CI to publish for real or set CI=true to override!`,
360
+ );
361
+ return;
362
+ }
363
+
364
+ console.info(`Updating all changed packages to version ${version}...`);
365
+ // Update each package to the new version
366
+ for (const pkg of changedPackages) {
367
+ console.info(` Updating ${pkg.name} version to ${version}...`);
368
+
369
+ await updatePackageJson(
370
+ path.resolve(rootDir, pkg.packageDir, "package.json"),
371
+ (config) => {
372
+ config.version = version;
373
+ },
374
+ );
375
+ }
376
+
377
+ console.info();
378
+ console.info(`Publishing all packages to npm with tag "${npmTag}"`);
379
+
380
+ // Publish each package
381
+ for (const pkg of changedPackages) {
382
+ const packageDir = path.join(rootDir, pkg.packageDir);
383
+
384
+ const cmd = `cd ${packageDir} && pnpm publish --tag ${npmTag} --access=public --no-git-checks`;
385
+ console.info(` Publishing ${pkg.name}@${version} to npm...`);
386
+ execSync(cmd, {
387
+ // @ts-ignore
388
+ stdio: [process.stdin, process.stdout, process.stderr],
389
+ });
390
+ }
391
+
392
+ console.info();
393
+ console.info("Committing changes...");
394
+ //execSync(`git add -A && git commit -m "${releaseCommitMsg(version)}"`);
395
+ execSync(
396
+ `git add -A && git reset -- ${changedPackages
397
+ .map((pkg) => path.resolve(rootDir, pkg.packageDir, "package.json"))
398
+ .join(" ")}`,
399
+ );
400
+ execSync(
401
+ `git checkout -- ${changedPackages
402
+ .map((pkg) => path.resolve(rootDir, pkg.packageDir, "package.json"))
403
+ .join(" ")}`,
404
+ );
405
+ execSync(`git commit -m "${releaseCommitMsg(version)}" --allow-empty`);
406
+ console.info(" Committed Changes.");
407
+
408
+ console.info();
409
+ console.info("Pushing changes...");
410
+ execSync("git push");
411
+ console.info(" Changes pushed.");
412
+
413
+ console.info();
414
+ console.info(`Creating new git tag v${version}`);
415
+ execSync(`git tag -a -m "v${version}" v${version}`);
416
+
417
+ console.info();
418
+ console.info("Pushing tags...");
419
+ execSync("git push --tags");
420
+ console.info(" Tags pushed.");
421
+
422
+ if (ghToken && isMainBranch) {
423
+ console.info();
424
+ console.info("Creating github release...");
425
+
426
+ // Stringify the markdown to escape any quotes
427
+ execSync(
428
+ `gh release create v${version} ${
429
+ branchConfig.prerelease ? "--prerelease" : ""
430
+ } --notes '${changelogMd.replace(/'/g, '"')}'`,
431
+ { env: { ...process.env, GH_TOKEN: ghToken } },
432
+ );
433
+ console.info(" Github release created.");
434
+ }
435
+
436
+ console.info();
437
+ console.info("All done!");
438
+ };
@@ -0,0 +1,50 @@
1
+ // @ts-check
2
+
3
+ import jsonfile from 'jsonfile';
4
+
5
+ /** @param {string} version */
6
+ export const releaseCommitMsg = (version) => `release: v${version}`;
7
+
8
+ /** @param {string} str */
9
+ export const capitalize = (str) => {
10
+ return str.slice(0, 1).toUpperCase() + str.slice(1);
11
+ };
12
+
13
+ /**
14
+ * @param {string} pathName
15
+ * @returns {Promise<import('type-fest').PackageJson>}
16
+ */
17
+ export const readPackageJson = async (pathName) => {
18
+ return await jsonfile.readFile(pathName);
19
+ };
20
+
21
+ /**
22
+ * @param {string} pathName
23
+ * @param {(json: import('type-fest').PackageJson) => Promise<void> | void} transform
24
+ */
25
+ export const updatePackageJson = async (pathName, transform) => {
26
+ const json = await readPackageJson(pathName);
27
+ await transform(json);
28
+ await jsonfile.writeFile(pathName, json, {
29
+ spaces: 2,
30
+ });
31
+ };
32
+
33
+ /**
34
+ * @template TItem
35
+ * @param {((d: TItem) => number)} sorter
36
+ * @returns {(a: TItem, b: TItem) => number}
37
+ */
38
+ export const getSorterFn = (sorter) => {
39
+ return (a, b) => {
40
+ const sortedA = sorter(a);
41
+ const sortedB = sorter(b);
42
+ if (sortedA > sortedB) {
43
+ return 1;
44
+ }
45
+ if (sortedA < sortedB) {
46
+ return -1;
47
+ }
48
+ return 0;
49
+ };
50
+ };