Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose the EdgeDB CLI as npx edgedb #931

Merged
merged 19 commits into from Apr 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Expand Up @@ -20,8 +20,8 @@
"scripts": {
"lint": "tslint 'packages/*/src/**/*.ts'",
"eslint": "eslint 'packages/*/src/**/*.ts'",
"format:check": "prettier --check 'packages/*/src/**/*.ts' 'packages/*/test/**/*.ts'",
"format": "prettier --write 'packages/*/src/**/*.ts' 'packages/*/test/**/*.ts'",
"format:check": "prettier --check 'packages/*/src/**/*.(mts|ts)' 'packages/*/test/**/*.ts'",
"format": "prettier --write 'packages/*/src/**/*.(mts|ts)' 'packages/*/test/**/*.ts'",
"generate": "yarn workspace @edgedb/generate generate"
}
}
2 changes: 1 addition & 1 deletion packages/driver/buildDeno.ts
Expand Up @@ -12,7 +12,7 @@ await run({
destDir: "../deno",
destEntriesToClean: ["_src", "mod.ts"],
sourceFilter: (path) => {
return !/\/syntax\//.test(path);
return !(/\/syntax\//.test(path) || /cli\.mts$/.test(path));
},
pathRewriteRules: [
{ match: /^src\/index.node.ts$/, replace: "mod.ts" },
Expand Down
12 changes: 10 additions & 2 deletions packages/driver/package.json
Expand Up @@ -22,11 +22,12 @@
"./dist/index.node.js": "./dist/index.browser.js"
},
"bin": {
"edgeql-js": "./dist/cli.js"
"edgedb": "./dist/cli.mjs"
},
"devDependencies": {
"@js-temporal/polyfill": "0.4.3",
"@types/jest": "^29.5.2",
"@types/which": "^3.0.3",
"fast-check": "^3.10.0",
"get-stdin": "^9.0.0",
"globby": "^13.2.0",
Expand All @@ -38,7 +39,8 @@
},
"scripts": {
"typecheck": "tsc --project tsconfig.json --noEmit",
"build": "echo 'Building edgedb-js...' && rm -rf dist && yarn build:cjs && yarn build:deno",
"build": "echo 'Building edgedb-js...' && rm -rf dist && yarn build:cjs && yarn build:cli && yarn build:deno",
"build:cli": "tsc --project tsconfig.cli.json",
"build:cjs": "tsc --project tsconfig.json",
"build:deno": "deno run --unstable --allow-all ./buildDeno.ts",
"test": "npx --node-options='--experimental-fetch' jest --detectOpenHandles",
Expand All @@ -47,5 +49,11 @@
"gen-errors": "edb gen-errors-json --client | node genErrors.mjs",
"watch": "nodemon -e js,ts,tsx --ignore dist -x ",
"dev": "yarn tsc --project tsconfig.json --incremental && yarn build:deno"
},
"dependencies": {
"debug": "^4.3.4",
"env-paths": "^3.0.0",
"semver": "^7.6.0",
"which": "^4.0.0"
}
}
303 changes: 303 additions & 0 deletions packages/driver/src/cli.mts
@@ -0,0 +1,303 @@
#!/usr/bin/env node
import { execSync, type ExecSyncOptions } from "node:child_process";
import { createWriteStream } from "node:fs";
import * as os from "node:os";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as process from "node:process";
import * as semver from "semver";
import envPaths from "env-paths";
import Debug from "debug";
import which from "which";

const debug = Debug("edgedb:cli");

const IS_TTY = process.stdout.isTTY;
const EDGEDB_PKG_ROOT = "https://packages.edgedb.com";
const CACHE_DIR = envPaths("edgedb").cache;
const TEMPORARY_CLI_PATH = path.join(CACHE_DIR, "/edgedb-cli");
const CLI_LOCATION_CACHE_FILE_PATH = path.join(CACHE_DIR, "/cli-location");

interface Package {
name: string;
version: string;
revision: string;
installref: string;
}

try {
await main(process.argv.slice(2));
process.exit(0);
} catch (err) {
console.error(err);
if (
typeof err === "object" &&
err !== null &&
"code" in err &&
typeof err.code === "number"
) {
process.exit(err.code);
}

process.exit(1);
}

async function main(args: string[]) {
debug("Starting main function with args:", args);
const cliLocation =
(await whichEdgeDbCli()) ??
(await getCliLocationFromCache()) ??
(await getCliLocationFromTempCli()) ??
(await selfInstallFromTempCli()) ??
null;

if (cliLocation === null) {
throw Error("Failed to find or install EdgeDB CLI.");
}

return runEdgeDbCli(args, cliLocation);
}

async function whichEdgeDbCli() {
debug("Checking if CLI is in PATH...");
const location = await which("edgedb", { nothrow: true });
if (location) {
debug(` - CLI found in PATH at: ${location}`);
return location;
}
debug(" - No CLI found in PATH.");
return null;
}

async function getCliLocationFromCache(): Promise<string | null> {
debug("Checking CLI cache...");
try {
const cachedBinaryPath = (
await fs.readFile(CLI_LOCATION_CACHE_FILE_PATH, { encoding: "utf8" })
).trim();
debug(" - CLI path in cache at:", cachedBinaryPath);

try {
await fs.access(cachedBinaryPath, fs.constants.F_OK);
debug(" - CLI binary found in path:", cachedBinaryPath);
return cachedBinaryPath;
} catch (err) {
debug(" - No CLI found in cache.", err);
return null;
}
} catch (err) {
debug(" - Cache file cannot be read.", err);
return null;
}
}

async function getCliLocationFromTempCli(): Promise<string | null> {
debug("Installing temporary CLI to get install directory...");
await downloadCliPackage();

const installDir = getInstallDir(TEMPORARY_CLI_PATH);
const binaryPath = path.join(installDir, "edgedb");
await fs.writeFile(CLI_LOCATION_CACHE_FILE_PATH, binaryPath, {
encoding: "utf8",
});
debug(" - CLI installed at:", binaryPath);

try {
debug(" - CLI binary found in path:", binaryPath);
await fs.access(binaryPath, fs.constants.F_OK);
return binaryPath;
} catch {
debug(" - CLI binary not found in path:", binaryPath);
return null;
}
}

async function selfInstallFromTempCli(): Promise<string | null> {
debug("Self-installing EdgeDB CLI...");
runEdgeDbCli(["_self_install"], TEMPORARY_CLI_PATH);
debug(" - CLI self-installed successfully.");
return getCliLocationFromCache();
}

async function downloadCliPackage() {
if (IS_TTY) {
console.log("No EdgeDB CLI found, downloading CLI package...");
}
debug("Downloading CLI package...");
const cliPkg = await findPackage();
const downloadDir = path.dirname(TEMPORARY_CLI_PATH);
await fs.mkdir(downloadDir, { recursive: true }).catch((error) => {
if (error.code !== "EEXIST") throw error;
});
const downloadUrl = new URL(cliPkg.installref, EDGEDB_PKG_ROOT);
await downloadFile(downloadUrl, TEMPORARY_CLI_PATH);
debug(" - CLI package downloaded to:", TEMPORARY_CLI_PATH);

const fd = await fs.open(TEMPORARY_CLI_PATH, "r+");
await fd.chmod(0o755);
await fd.datasync();
await fd.close();
}

function runEdgeDbCli(
args: string[],
pathToCli: string | null,
execOptions: ExecSyncOptions = { stdio: "inherit" }
) {
const cliCommand = pathToCli ?? "edgedb";
const command = `${cliCommand} ${args.join(" ")}`;
debug(`Running EdgeDB CLI: ${command}`);
return execSync(command, execOptions);
}

async function findPackage(): Promise<Package> {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of this logic is adapted from https://github.com/edgedb/setup-edgedb/blob/main/src/main.ts#L123 and ought to be centralized into a separate package to keep them in sync with each other, but there are some slight differences between the GitHub Actions runtime and a plain Node runtime, so I decided the easiest thing to do for now was to copy.

We can still look at making a common abstraction they can share (probably this logic without the side effects), but I don't want to block this effort on that.

const arch = os.arch();
const platform = os.platform();
const includeCliPrereleases = true;
const cliVersionRange = ">=4.1.1";
const libc = platform === "linux" ? "musl" : "";
const dist = getBaseDist(arch, platform, libc);

debug(`Finding compatible package for ${dist}...`);
const versionMap = await getVersionMap(dist);
const pkg = await getMatchingPkg(
versionMap,
cliVersionRange,
includeCliPrereleases
);
if (!pkg) {
throw Error(
`No compatible EdgeDB CLI package found for the current platform ${dist}`
);
}
debug(" - Package found:", pkg);
return pkg;
}

async function getVersionMap(dist: string): Promise<Map<string, Package>> {
debug("Getting version map for distribution:", dist);
const indexRequest = await fetch(
new URL(`archive/.jsonindexes/${dist}.json`, EDGEDB_PKG_ROOT)
);
const index = (await indexRequest.json()) as { packages: Package[] };
const versionMap = new Map();

for (const pkg of index.packages) {
if (pkg.name !== "edgedb-cli") {
continue;
}

if (
!versionMap.has(pkg.version) ||
versionMap.get(pkg.version).revision < pkg.revision
) {
versionMap.set(pkg.version, pkg);
}
}

return versionMap;
}

async function getMatchingPkg(
versionMap: Map<string, Package>,
cliVersionRange: string,
includeCliPrereleases: boolean
): Promise<Package | null> {
debug("Getting matching version for range:", cliVersionRange);
let matchingPkg: Package | null = null;
for (const [version, pkg] of versionMap.entries()) {
if (
semver.satisfies(version, cliVersionRange, {
includePrerelease: includeCliPrereleases,
})
) {
if (
!matchingPkg ||
semver.compareBuild(version, matchingPkg.version) > 0
) {
matchingPkg = pkg;
}
}
}

if (matchingPkg) {
debug(" - Matching version found:", matchingPkg.version);
return matchingPkg;
} else {
throw Error(
"no published EdgeDB CLI version matches requested version " +
`'${cliVersionRange}'`
);
}
}

async function downloadFile(url: string | URL, path: string) {
debug("Downloading file from URL:", url);
const response = await fetch(url);
if (!response.ok || !response.body) {
throw new Error(` - Download failed: ${response.statusText}`);
}

const fileStream = createWriteStream(path, { flush: true });

if (response.body) {
for await (const chunk of streamReader(response.body)) {
fileStream.write(chunk);
}
fileStream.end();
debug(" - File downloaded successfully.");
} else {
throw new Error(" - Download failed: no response body");
}
}

function getBaseDist(arch: string, platform: string, libc = ""): string {
debug("Getting base distribution for:", arch, platform, libc);
let distArch = "";
let distPlatform = "";

if (platform === "linux") {
if (libc === "") {
libc = "gnu";
}
distPlatform = `unknown-linux-${libc}`;
} else if (platform === "darwin") {
distPlatform = "apple-darwin";
} else {
throw Error(`This action cannot be run on ${platform}`);
}

if (arch === "x64") {
distArch = "x86_64";
} else if (arch === "arm64") {
distArch = "aarch64";
} else {
throw Error(`This action does not support the ${arch} architecture`);
}

const dist = `${distArch}-${distPlatform}`;
debug(" - Base distribution:", dist);
return dist;
}

function getInstallDir(cliPath: string): string {
debug("Getting install directory for CLI path:", cliPath);
const installDir = runEdgeDbCli(["info", "--get", "'install-dir'"], cliPath, {
stdio: "pipe",
})
.toString()
.trim();
debug(" - Install directory:", installDir);
return installDir;
}

async function* streamReader(readableStream: ReadableStream<Uint8Array>) {
debug("Reading stream...");
const reader = readableStream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
yield value;
}
debug(" - Stream reading completed.");
}
20 changes: 0 additions & 20 deletions packages/driver/src/cli.ts

This file was deleted.

11 changes: 11 additions & 0 deletions packages/driver/tsconfig.cli.json
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "node16",
"moduleResolution": "node16",
"outDir": "./dist",
"declaration": false
},
"include": ["src/cli.mts"]
}