diff --git a/package.json b/package.json index d676762e5..ef8f14ba4 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/packages/driver/buildDeno.ts b/packages/driver/buildDeno.ts index d51586b3e..ec03aff89 100644 --- a/packages/driver/buildDeno.ts +++ b/packages/driver/buildDeno.ts @@ -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" }, diff --git a/packages/driver/package.json b/packages/driver/package.json index f97f7a2fe..df298afc2 100644 --- a/packages/driver/package.json +++ b/packages/driver/package.json @@ -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", @@ -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", @@ -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" } } diff --git a/packages/driver/src/cli.mts b/packages/driver/src/cli.mts new file mode 100644 index 000000000..85393cecb --- /dev/null +++ b/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 { + 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 { + 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 { + 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 { + 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> { + 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, + cliVersionRange: string, + includeCliPrereleases: boolean +): Promise { + 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) { + debug("Reading stream..."); + const reader = readableStream.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + yield value; + } + debug(" - Stream reading completed."); +} diff --git a/packages/driver/src/cli.ts b/packages/driver/src/cli.ts deleted file mode 100644 index aa6a232a9..000000000 --- a/packages/driver/src/cli.ts +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env node - -// tslint:disable:no-console -console.log( - `Failure: The \`npx edgeql-js\` command is no longer supported. - -To generate the EdgeDB query builder, install \`@edgedb/generate\` -package as a dev dependency in your local project. This package implements -a set of code generation tools for EdgeDB. - - $ npm install -D @edgedb/generate (npm) - $ yarn add -D @edgedb/generate (yarn) - -Then run the following command to generate the query builder. - - $ npx @edgedb/generate edgeql-js -` -); - -export {}; diff --git a/packages/driver/tsconfig.cli.json b/packages/driver/tsconfig.cli.json new file mode 100644 index 000000000..17acad0ad --- /dev/null +++ b/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"] +} + diff --git a/packages/driver/tsconfig.esm.json b/packages/driver/tsconfig.esm.json index 811c47a98..74ea3ea9e 100644 --- a/packages/driver/tsconfig.esm.json +++ b/packages/driver/tsconfig.esm.json @@ -4,23 +4,8 @@ "module": "es2015", "outDir": "./dist/__esm", "declaration": false, - "declarationDir": null, - // "paths": { - // "edgedb": [ - // "./src/index.node" - // ], - // "@generated/*": [ - // "./src/syntax/genMock/*" - // ], - // } + "declarationDir": null }, - "include": [ - "src/syntax", - ], - "exclude": [ - "**/*.deno.ts", - "test/deno/*", - "dist", - "qb", - ] + "include": ["src/syntax"], + "exclude": ["**/*.deno.ts", "test/deno/*", "dist", "qb"] } diff --git a/packages/driver/tsconfig.json b/packages/driver/tsconfig.json index 73bb1f242..adcac3d7f 100644 --- a/packages/driver/tsconfig.json +++ b/packages/driver/tsconfig.json @@ -4,29 +4,7 @@ "declarationDir": "./dist", "outDir": "./dist", "downlevelIteration": true - // "baseUrl": ".", - // "typeRoots": [ - // "./node_modules/@types" - // ], - // "paths": { - // "edgedb": [ - // "./src/index.node" - // ], - // "@generated/*": [ - // "./src/syntax/genMock/*" - // ], - // } }, - "include": [ - "src", - // "../generate/src/generate.ts", - // "../generate/src/util/functionUtils.ts", - // "../generate/src/util/genutil.ts", - ], - "exclude": [ - "**/*.deno.ts", - // "test/deno/*", - "dist", - // "qb", - ] + "include": ["src"], + "exclude": ["src/cli.mts", "**/*.deno.ts", "dist"] } diff --git a/yarn.lock b/yarn.lock index df2640e3a..01f504120 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1605,6 +1605,11 @@ resolved "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz" integrity sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw== +"@types/which@^3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/which/-/which-3.0.3.tgz#41142ed5a4743128f1bc0b69c46890f0453ddb89" + integrity sha512-2C1+XoY0huExTbs8MQv1DuS5FS86+SEjdM9F/+GS61gg5Hqbtj8ZiDSx8MfWcyei907fIPbfPGCOrNUTnVHY1g== + "@types/yargs-parser@*": version "21.0.0" resolved "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz" @@ -2403,6 +2408,11 @@ entities@^4.4.0: resolved "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz" integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== +env-paths@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-3.0.0.tgz#2f1e89c2f6dbd3408e1b1711dd82d62e317f58da" + integrity sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A== + error-ex@^1.3.1: version "1.3.2" resolved "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz" @@ -3313,6 +3323,11 @@ isexe@^2.0.0: resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== +isexe@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-3.1.1.tgz#4a407e2bd78ddfb14bea0c27c6f7072dde775f0d" + integrity sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ== + istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz" @@ -4583,6 +4598,13 @@ semver@^6.0.0, semver@^6.3.0: resolved "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== +semver@^7.6.0: + version "7.6.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" + integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== + dependencies: + lru-cache "^6.0.0" + send@0.18.0: version "0.18.0" resolved "https://registry.npmjs.org/send/-/send-0.18.0.tgz" @@ -5260,6 +5282,13 @@ which@^2.0.1: dependencies: isexe "^2.0.0" +which@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/which/-/which-4.0.0.tgz#cd60b5e74503a3fbcfbf6cd6b4138a8bae644c1a" + integrity sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg== + dependencies: + isexe "^3.1.1" + word-wrap@^1.2.3, word-wrap@~1.2.3: version "1.2.3" resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz"