Skip to content

Commit

Permalink
Add chronus pack command (#92)
Browse files Browse the repository at this point in the history
  • Loading branch information
timotheeguerin authored Feb 27, 2024
1 parent a355584 commit c44c1fd
Show file tree
Hide file tree
Showing 17 changed files with 270 additions and 2 deletions.
8 changes: 8 additions & 0 deletions .chronus/changes/feature-pack-2024-1-27-2-7-8.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
# Change versionKind to one of: breaking, feature, fix, internal
changeKind: feature
packages:
- "@chronus/chronus"
---

Add `chronus pack` command that will pack all packages that need publishing
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@
"files.eol": "\n",
"prettier.prettierPath": "./node_modules/prettier/index.cjs",
"typescript.tsdk": "node_modules/typescript/lib",
"eslint.experimental.useFlatConfig": true,
}
21 changes: 20 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,24 @@ This allows to ignore the [version policies](version-policies.md) and bump the p
Only bumps the packages specified. This can be useful if wanting to only release certain packages. This command will extract the change descriptions for the specified packages and bump the version of those packages. If a change applied to a package is not specified in the `--only` option it will be ignored. If a change is specified in both it will be applied and the packages included in the `only` array will be removed from the change description file.

```bash
chronus version --only @my-scope/my-package1 --only @my-scope/my-package2
$ chronus version --only @my-scope/my-package1 --only @my-scope/my-package2
```

## `chronus pack`

Pack all the packages configured for the workspace.

By default it will have the same effect as `npm pack` run in each package directory.

### Options

#### `--pack-destination`

Directory where the packed packages will be placed. By default each tar file will be placed in the package directory.

```bash
$ chronus pack --pack-destination /temp/artifacts

✔ @chronus/chronus packed in chronus-chronus-0.7.0.tgz (94.5 kB)
✔ @chronus/github-pr-commenter packed in chronus-github-pr-commenter-0.3.0.tgz (5.49 kB)
```
1 change: 1 addition & 0 deletions packages/chronus/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"prompts": "^2.4.2",
"semver": "^7.6.0",
"source-map-support": "^0.5.21",
"std-env": "^3.7.0",
"vitest": "^1.3.1",
"yargs": "^17.7.2",
"zod": "^3.22.4"
Expand Down
30 changes: 30 additions & 0 deletions packages/chronus/src/cli/cli.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import "source-map-support/register.js";
import yargs from "yargs";
import { DynamicReporter, type Reporter } from "../reporters/index.js";
import { resolvePath } from "../utils/path-utils.js";
import { addChangeset } from "./commands/add-changeset.js";
import { applyChangesets } from "./commands/apply-changesets.js";
import { listPendingPublish } from "./commands/list-pending-publish.js";
import { pack } from "./commands/pack.js";
import { showStatus } from "./commands/show-status.js";
import { verifyChangeset } from "./commands/verify-changeset.js";

Expand Down Expand Up @@ -69,10 +72,37 @@ async function main() {
}),
(args) => listPendingPublish(process.cwd(), { json: args.json }),
)
.command(
["pack"],
"Pack all packages that can be published",
(cmd) =>
cmd.option("pack-destination", {
type: "string",
description: "Containing directory for the packed packages. Default to each package own directory.",
}),
withReporter((args) =>
pack({
reporter: args.reporter,
dir: process.cwd(),
packDestination: args.packDestination && resolveCliPath(args.packDestination),
}),
),
)
.demandCommand(1, "You need at least one command before moving on")
.parse();
}

function resolveCliPath(path: string) {
return resolvePath(process.cwd(), path);
}

function withReporter<T>(fn: (reporter: T & { reporter: Reporter }) => Promise<void>): (args: T) => Promise<void> {
return (args: T) => {
const reporter = new DynamicReporter();
return fn({ reporter, ...args });
};
}

main().catch((error) => {
// eslint-disable-next-line no-console
console.log("Error", error);
Expand Down
27 changes: 27 additions & 0 deletions packages/chronus/src/cli/commands/pack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import pc from "picocolors";
import { NodeChronusHost, loadChronusWorkspace } from "../../index.js";
import { packPackage } from "../../pack/index.js";
import type { Reporter } from "../../reporters/index.js";
import { prettyBytes } from "../../utils/misc-utils.js";

export interface PackOptions {
readonly reporter: Reporter;

readonly dir: string;

/** Directory that should contain the generated `.tgz` */
readonly packDestination?: string;
}

export async function pack({ reporter, dir, packDestination }: PackOptions) {
const host = NodeChronusHost;
const workspace = await loadChronusWorkspace(host, dir);
for (const pkg of workspace.packages) {
await reporter.task(`${pc.yellow(pkg.name)} packing`, async (task) => {
const result = await packPackage(workspace, pkg, packDestination);
task.update(
`${pc.yellow(pkg.name)} packed in ${pc.cyan(result.filename)} (${pc.magenta(prettyBytes(result.size))})`,
);
});
}
}
1 change: 1 addition & 0 deletions packages/chronus/src/pack/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { packPackage } from "./pack.js";
65 changes: 65 additions & 0 deletions packages/chronus/src/pack/pack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { mkdir } from "fs/promises";
import { execAsync } from "../utils/exec-async.js";
import { resolvePath } from "../utils/path-utils.js";
import type { Package, WorkspaceType } from "../workspace-manager/types.js";
import type { ChronusWorkspace } from "../workspace/types.js";

export interface PackPackageResult {
readonly id: string;
readonly name: string;
readonly version: string;
/** Name of the .tgz file created */
readonly filename: string;
/** Absolute path of the .tgz file created. */
readonly path: string;
readonly size: number;
readonly unpackedSize: number;
}

export async function packPackage(
workspace: ChronusWorkspace,
pkg: Package,
destination?: string,
): Promise<PackPackageResult> {
const pkgDir = resolvePath(workspace.path, pkg.relativePath);
const packDestination = destination ?? pkgDir;
await mkdir(packDestination, { recursive: true }); // Not using the ChronusHost here because it doesn't matter as we need to call npm after.
const command = getPackCommand(workspace.workspace.type, packDestination);
const result = await execAsync(command.command, command.args, { cwd: pkgDir });
if (result.code !== 0) {
throw new Error(`Failed to pack package ${pkg.name} at ${pkg.relativePath}. Log:\n${result.stdall}`);
}

const parsedResult = JSON.parse(result.stdout.toString())[0];
return {
id: parsedResult.id,
name: parsedResult.name,
version: parsedResult.version,
filename: parsedResult.filename,
path: resolvePath(packDestination, parsedResult.filename),
size: parsedResult.size,
unpackedSize: parsedResult.unpackedSize,
};
}

function getPackCommand(type: WorkspaceType, destination: string): Command {
switch (type) {
// case "pnpm":
// return getPnpmCommand(destination);
case "npm":
default:
return getNpmCommand(destination);
}
}

interface Command {
readonly command: string;
readonly args: string[];
}

// function getPnpmCommand(destination: string): Command {
// return { command: "pnpm", args: ["pack", "--json", "--pack-destination", destination] };
// }
function getNpmCommand(destination: string): Command {
return { command: "npm", args: ["pack", "--json", "--pack-destination", destination] };
}
24 changes: 24 additions & 0 deletions packages/chronus/src/reporters/basic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import pc from "picocolors";
import { isCI } from "std-env";
import type { Reporter, Task } from "./types.js";

export class BasicReporter implements Reporter {
isTTY = process.stdout?.isTTY && !isCI;

log(message: string) {
// eslint-disable-next-line no-console
console.log(message);
}

async task(message: string, action: (task: Task) => Promise<void>) {
let current = message;
const task = {
update: (newMessage: string) => {
current = newMessage;
},
};
this.log(`${pc.yellow("-")} ${current}`);
await action(task);
this.log(`${pc.green("✔")} ${current}`);
}
}
33 changes: 33 additions & 0 deletions packages/chronus/src/reporters/dynamic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import pc from "picocolors";
import { BasicReporter } from "./basic.js";
import type { Reporter, Task } from "./types.js";
import { createSpinner } from "./utils.js";

export class DynamicReporter extends BasicReporter implements Reporter {
async task(message: string, action: (task: Task) => Promise<void>) {
if (!this.isTTY) {
return super.task(message, action);
}

let current = message;
const task = {
update: (newMessage: string) => {
current = newMessage;
},
};

const spinner = createSpinner();
const interval = setInterval(() => {
this.#printProgress(`\r${pc.yellow(spinner())} ${current}`);
}, 300);
await action(task);
clearInterval(interval);
this.#printProgress(`\r${pc.green("✔")} ${current}\n`);
}

#printProgress(content: string) {
process.stdout.clearLine(0);
process.stdout.cursorTo(0);
process.stdout.write(content);
}
}
3 changes: 3 additions & 0 deletions packages/chronus/src/reporters/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { BasicReporter } from "./basic.js";
export { DynamicReporter } from "./dynamic.js";
export type { Reporter, Task } from "./types.js";
8 changes: 8 additions & 0 deletions packages/chronus/src/reporters/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface Reporter {
readonly log: (message: string) => void;
readonly task: (message: string, action: (task: Task) => Promise<void>) => Promise<void>;
}

export interface Task {
readonly update: (message: string) => void;
}
11 changes: 11 additions & 0 deletions packages/chronus/src/reporters/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const spinnerFrames =
process.platform === "win32" ? ["-", "\\", "|", "/"] : ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];

export function createSpinner() {
let index = 0;

return () => {
index = ++index % spinnerFrames.length;
return spinnerFrames[index];
};
}
6 changes: 5 additions & 1 deletion packages/chronus/src/utils/exec-async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,28 @@ import crosspawn from "cross-spawn";

export interface ExecResult {
readonly code: number | null;
readonly stdall: Buffer;
readonly stdout: Buffer;
readonly stderr: Buffer;
}
export function execAsync(cmd: string, args: string[], opts: SpawnOptions): Promise<ExecResult> {
return new Promise((resolve, reject) => {
const child = crosspawn(cmd, args, opts);
let stdall = Buffer.from("");
let stdout = Buffer.from("");
let stderr = Buffer.from("");

if (child.stdout) {
child.stdout.on("data", (data) => {
stdout = Buffer.concat([stdout, data]);
stdall = Buffer.concat([stdall, data]);
});
}

if (child.stderr) {
child.stderr.on("data", (data) => {
stderr = Buffer.concat([stderr, data]);
stdall = Buffer.concat([stdall, data]);
});
}

Expand All @@ -29,7 +33,7 @@ export function execAsync(cmd: string, args: string[], opts: SpawnOptions): Prom
});

child.on("close", (code) => {
resolve({ code, stdout, stderr });
resolve({ code, stdout, stderr, stdall });
});
});
}
29 changes: 29 additions & 0 deletions packages/chronus/src/utils/misc-utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,32 @@
export function isDefined<T>(arg: T | undefined): arg is T {
return arg !== undefined;
}

const UNITS = ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];

/**
* Format a number of bytes by addding KB, MB, GB, etc. after
* @param bytes Number of bytes to prettify
* @param perecision Number of decimals to keep. @default 2
*/
export function prettyBytes(bytes: number, decimals = 2) {
if (!Number.isFinite(bytes)) {
throw new TypeError(`Expected a finite number, got ${typeof bytes}: ${bytes}`);
}

const neg = bytes < 0;

if (neg) {
bytes = -bytes;
}

if (bytes < 1) {
return (neg ? "-" : "") + bytes + " B";
}

const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1000)), UNITS.length - 1);
const numStr = Number((bytes / Math.pow(1000, exponent)).toFixed(decimals));
const unit = UNITS[exponent];

return (neg ? "-" : "") + numStr + " " + unit;
}
1 change: 1 addition & 0 deletions packages/chronus/src/workspace-manager/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export type WorkspaceType = "npm" | "pnpm" | "rush";

export interface Workspace {
readonly type: WorkspaceType;
readonly path: string;
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit c44c1fd

Please sign in to comment.