brocolito
Version:
Create type-safe CLIs to align local development and pipeline workflows
158 lines (140 loc) • 4.27 kB
JavaScript
import path from "node:path";
import fs from "node:fs/promises";
import { fileURLToPath } from "node:url";
import process from "node:process";
const VALID_CLI_NAME = /^[a-zA-Z0-9_-]+$/;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const buildDir = path.resolve("./build/");
const binDir = path.join(buildDir, "bin");
const getPackageJson = async () => {
const packageJSONContent = await fs
.readFile("package.json", "utf-8")
.catch(() => fs.readFile("deno.json", "utf-8"))
.catch(() => {
throw new Error("Cannot find package.json nor deno.json");
});
return JSON.parse(packageJSONContent);
};
export const getPackageJsonDependencies = async () => {
const packageJSON = await getPackageJson();
// "imports" in "deno.json"
return Object.keys(packageJSON.dependencies || packageJSON.imports);
};
export const getGlobalState = async () => {
const packageJSON = await getPackageJson();
const dir = path.resolve(".");
const globalState = {
name: packageJSON.name || path.basename(dir),
dir,
version: packageJSON.version,
aliases: packageJSON.brocolito?.aliases,
};
if (!VALID_CLI_NAME.test(globalState.name)) {
throw new Error(
`Please use valid name for the CLI in your package.json (current: ${globalState.name}). Satisfying the constraint: ${VALID_CLI_NAME}`,
);
}
return globalState;
};
/**
* @param {{name: string; dir: string; version?: string }} globalState
*/
export const createGlobalStateFile = async (globalState) => {
// create execution wrapper
const file = path.join(buildDir, "meta.js");
await fs.writeFile(
file,
`globalThis.__BROCOLITO__=${JSON.stringify(globalState)};\n`,
);
};
/**
* @param {string} name
* @param {(file: string) => Promise<void>} createCb
*/
export const createBinFile = async (name, createCb) => {
// create execution wrapper
const binFile = path.join(binDir, name);
await fs.mkdir(binDir, { recursive: true });
await createCb(binFile);
await fs.chmod(binFile, "744");
};
/**
* @param {string} name
* @param {Record<string, string> | undefined} aliases
*/
export const createCompletionFiles = async (name, aliases) => {
// copy completion scripts into build dir
const bashCompletion = await fs.readFile(
path.join(__dirname, "bash_completion.sh"),
"utf-8",
);
const zshCompletion = await fs.readFile(
path.join(__dirname, "zsh_completion.sh"),
"utf-8",
);
const BRO_ALIASES = aliases
? Object.entries(aliases)
.map(([name, value]) => `alias ${name}="${value}"`)
.join("\n")
: "";
await fs.writeFile(
path.resolve("./build/bash_completion.sh"),
bashCompletion
.replace(
"BRO_ALIAS_COMPLETIONS",
aliases
? Object.keys(aliases)
.map(
(name) => `complete -o default -F _BRO_NAME_completion ${name}`,
)
.join("\n ")
: "",
)
.replace("BRO_ALIASES", BRO_ALIASES)
.replaceAll("BRO_NAME", name),
);
await fs.writeFile(
path.resolve("./build/zsh_completion.sh"),
zshCompletion
.replace("BRO_ALIASES", BRO_ALIASES)
.replaceAll("BRO_NAME", name),
);
};
/**
* @param {string} name
*/
export const showSetupHint = (name) => {
if (
!process.env.CI &&
!process.env.BROCOLITO_REBUILD &&
!(/** @type {string} */ (process.env.PATH).split(":").includes(binDir))
) {
console.log(`
To make the CLI ${name} globally accessible, you have to run this:
export PATH="${binDir}:$PATH"`);
}
};
/**
* @param {string[]} opts
*/
export const buildWithOpts = async (opts) => {
if (!["node", "bun", "deno"].includes(opts[0])) {
throw new Error(
"Only supported runtimes are node, bun or deno. You passed: " + opts[0],
);
}
const globalState = await getGlobalState();
await createBinFile(globalState.name, (binFile) =>
fs.writeFile(
binFile,
`#!/usr/bin/env ${opts.length > 1 ? "-S " : ""}${opts.join(" ")}
await import("../meta.js");
await import("../../src/main.ts");
`,
),
);
await createGlobalStateFile(globalState);
await createCompletionFiles(globalState.name, globalState.aliases);
showSetupHint(globalState.name);
};