diff --git a/.gitignore b/.gitignore index dba131f..c78bbb5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ +.jscratesrc.json +.jscrates-cache bin node_modules tars -.jscratesrc.json +jscrates diff --git a/README.md b/README.md index 7e5e1b9..54a2bfd 100644 --- a/README.md +++ b/README.md @@ -43,16 +43,23 @@ docker run -e HOME=/tmp -v $HOME/.jscrates/docker:/tmp/.jscrates -it --rm jscrat ## Commands -1. `jscrates download` +1. `unload` #### Description -Downloads the specified package from the official repository of JSCrates. +Downloads the specified package(s) from official repository of JSCrates. #### Usage ```bash -$ jscrates download [version] +jscrates unload +``` + +### Example + +```bash +jscrates unload physics bodmas@1.0.0 +jscrates unload @jscrates/cli @jscrates/unload@1.0.0 ``` 2. `publish` @@ -66,7 +73,7 @@ Have a package that you want to share with the world? This command will help you This command requires you to set or open the terminal in your project directory. ```bash -$ jscrates publish +jscrates publish ``` --- diff --git a/actions/download.js b/actions/download.js deleted file mode 100644 index fad15f9..0000000 --- a/actions/download.js +++ /dev/null @@ -1,60 +0,0 @@ -// @ts-check - -import { get } from 'https' -import { createWriteStream } from 'fs' -import Spinner from 'mico-spinner' -import semver from 'semver' -import api from '../lib/api/index.js' -import { logError } from '../utils/loggers.js' - -/** - * Action to download packages from repository. - * - * @param {string} name - * @param {string | semver.SemVer} version - */ -async function downloadPackage(name, version) { - // Initialize a spinner instance - const downloadingSpinner = Spinner(`Downloading ${name}`) - - try { - if (version) { - // Validating version against Semantic Versioning rules - if (!semver.valid(version)) { - throw `Invalid version` - } - } - - // Initiate the spinner - downloadingSpinner.start() - - const endpoint = ['pkg', name, version].filter(Boolean).join('/') - - const res = (await api.get(endpoint)).data - - // Create a write file stream to download the tar file - const file = createWriteStream( - `./tars/${res?.dist?.tarball?.substring( - res?.dist?.tarball?.lastIndexOf('/') + 1 - )}` - ) - - // Initiate the HTTP request to download package archive - // (.targz) files from the cloud repository - get(res?.dist?.tarball, function (response) { - response.pipe(file) - }) - - downloadingSpinner.succeed() - } catch (error) { - downloadingSpinner.fail() - - if (error?.isAxiosError) { - return logError(error?.response?.data?.message) - } - - logError(error) - } -} - -export default downloadPackage diff --git a/actions/packages/unload.js b/actions/packages/unload.js new file mode 100644 index 0000000..e5600b8 --- /dev/null +++ b/actions/packages/unload.js @@ -0,0 +1,126 @@ +// @ts-check + +import https from 'https' +import { createWriteStream, createReadStream } from 'fs' +import Spinner from 'mico-spinner' +import tempDirectory from 'temp-dir' +import chalk from 'chalk' +import tar from 'tar' +import { getPackages } from '../../lib/api/actions.js' +import { logError } from '../../utils/loggers.js' +import upsertDir from '../../utils/upsert-dir.js' + +// This is the directory on the OS's temp location where +// crates will be cached to enable offline operations. +const cacheDir = tempDirectory + '/.jscrates-cache' +// Directory in the current project where packages will +// be installed (unzipped). Consider this as `node_modules` +// for JSCrates +const installDir = './jscrates' + +// Generates directory path suffixed with the package name. +const suffixPackageName = (baseDir, packageName) => baseDir + '/' + packageName + +// Used for storing packages in cache. +const generateCacheDirPath = (packageName = '') => + suffixPackageName(cacheDir, packageName) + +// Used for unzipping packages in the CWD. +const generateCratesInstallDir = (packageName = '') => + suffixPackageName(installDir, packageName) + +// Extracts tarball name from the provided URL. +const getTarballName = (tarballURL) => { + return tarballURL.substring(tarballURL.lastIndexOf('/') + 1) +} + +/** + * Action to download packages from repository. + * + * TODO: Implement logic to check packages in cache before + * requesting the API. + * + * @param {string[]} packages + */ +async function unloadPackages(packages, ...args) { + // Since we are accepting variadic arguments, other arguments can only + // be accessing by spreading them. + const store = args[1].__store + const spinner = Spinner(`Downloading packages`) + + try { + if (!store?.isOnline) { + return logError('Internet connection is required to download packages.') + } + + spinner.start() + + const response = await getPackages(packages) + + // `data` contains all the resolved packages metadata. + // 1. Download the tarball to cache directory. + // 2. Read the cached tarball & install in CWD. + response?.data?.map((res) => { + const timerLabel = chalk.green(`Installed \`${res.name}\` in`) + console.time(timerLabel) + + const tarballFileName = getTarballName(res?.dist?.tarball) + const cacheLocation = upsertDir(generateCacheDirPath(res?.name)) + const installLocation = upsertDir(generateCratesInstallDir(res?.name)) + + // Create a write file stream to download the tar file + const file = createWriteStream(`${cacheLocation}/${tarballFileName}`) + + // Initiate the HTTP request to download package archive + // (.tgz) files from the cloud repository + https.get(res?.dist?.tarball, function (response) { + response + .on('error', function () { + throw 'Something went wrong downloading the package.' + }) + .on('data', function (data) { + file.write(data) + }) + .on('end', function () { + file.end() + createReadStream(`${cacheLocation}/${tarballFileName}`).pipe( + tar.x({ cwd: installLocation }) + ) + }) + }) + + console.timeEnd(timerLabel) + }) + + console.log('\n') + + // When only a few packages are resolved, the errors array + // contains list of packages that were not resolved. + // We shall display these for better UX. + console.group( + chalk.yellow('The following errors occured during this operation:') + ) + + if (response?.errors?.length) { + logError(response?.errors?.join('\n')) + } + + console.groupEnd() + + console.log('\n') + + spinner.succeed() + } catch (error) { + spinner.fail() + + // When all the requested packages could not be resolved + // API responds with status 404 and list of errors. + if (Array.isArray(error)) { + return logError(error.join('\n')) + } + + return logError(error) + } +} + +export default unloadPackages diff --git a/jscrates.js b/jscrates.js index 1081e52..8aa1061 100644 --- a/jscrates.js +++ b/jscrates.js @@ -1,19 +1,17 @@ -// @ts-check +#!/usr/bin/env node -import { readFile } from 'fs/promises' import { Command } from 'commander' import Configstore from 'configstore' import checkOnlineStatus from 'is-online' import { CONFIG_FILE } from './lib/constants.js' -import downloadPackage from './actions/download.js' +import unloadPackages from './actions/packages/unload.js' import publishPackage from './actions/publish.js' import login from './actions/auth/login.js' import register from './actions/auth/register.js' import logout from './actions/auth/logout.js' async function jscratesApp() { - const packageJSON = JSON.parse(await readFile('./package.json', 'utf-8')) const isOnline = await checkOnlineStatus() const program = new Command() const configStore = new Configstore(CONFIG_FILE, { @@ -27,7 +25,8 @@ async function jscratesApp() { program .name('jscrates') .description(`Welcome to JSCrates 📦, yet another package manager for Node`) - .version(packageJSON.version, '-v, --version', 'display current version') + // TODO: Find a way to read version build time. + .version('0.0.0-alpha', '-v, --version', 'display current version') .hook('preAction', (_, actionCommand) => { actionCommand['__store'] = appState }) @@ -48,11 +47,17 @@ async function jscratesApp() { .action(logout(configStore)) program - .command('download') - .description(`Download a package from official JSCrates registry`) - .argument('', 'package to download') - .argument('[version]', 'version of the package to download') - .action(downloadPackage) + .command('unload') + .description('🔽 Download package(s) from the JSCrates registry') + .argument('', 'List of packages delimited by a space') + .action(unloadPackages) + .addHelpText( + 'after', + '\nExamples:\n jscrates unload bodmas' + + '\n jscrates unload physics-formulae@1.0.0' + + '\n jscrates unload binary-search merge-sort bodmas@1.0.0' + ) + .aliases(['u']) program .command('publish') diff --git a/lib/api/actions.js b/lib/api/actions.js index df8787b..891b430 100644 --- a/lib/api/actions.js +++ b/lib/api/actions.js @@ -7,6 +7,14 @@ const apiErrorHandler = (error) => { } } +const apiAction = async (bodyFn, errorHandlerFn = undefined) => { + try { + return await bodyFn() + } catch (error) { + return errorHandlerFn ? errorHandlerFn(error) : apiErrorHandler(error) + } +} + export const registerUser = async ({ email, password }) => { try { const { data: apiResponse } = await api.post('/auth/register', { @@ -31,3 +39,14 @@ export const loginUser = async ({ email, password }) => { return apiErrorHandler(error) } } + +export const getPackages = async (packages) => { + return await apiAction( + async () => { + return (await api.put('/pkg', { packages })).data + }, + (error) => { + throw error?.response?.data?.errors + } + ) +} diff --git a/package-lock.json b/package-lock.json index 987121c..9e8e55a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jscrates/cli", - "version": "2.5.1", + "version": "2.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@jscrates/cli", - "version": "2.5.1", + "version": "2.6.0", "dependencies": { "axios": "^0.24.0", "chalk": "^4.1.2", @@ -18,7 +18,6 @@ "lodash.kebabcase": "^4.1.1", "mico-spinner": "^1.4.0", "prompt": "^1.2.0", - "semver": "^7.3.5", "tar": "^6.1.11", "temp-dir": "^2.0.0" }, @@ -750,17 +749,6 @@ "node": ">=8" } }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -1233,20 +1221,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/signal-exit": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.6.tgz", @@ -1952,14 +1926,6 @@ "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==" }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "requires": { - "yallist": "^4.0.0" - } - }, "merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2289,14 +2255,6 @@ "queue-microtask": "^1.2.2" } }, - "semver": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", - "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", - "requires": { - "lru-cache": "^6.0.0" - } - }, "signal-exit": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.6.tgz", diff --git a/package.json b/package.json index 81320a8..b007bcf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jscrates/cli", - "version": "2.5.1", + "version": "2.6.0", "description": "Official CLI client for JSCrates.", "main": "jscrates.js", "author": "Team JSCrates", @@ -24,7 +24,6 @@ "lodash.kebabcase": "^4.1.1", "mico-spinner": "^1.4.0", "prompt": "^1.2.0", - "semver": "^7.3.5", "tar": "^6.1.11", "temp-dir": "^2.0.0" }, diff --git a/utils/upsert-dir.js b/utils/upsert-dir.js new file mode 100644 index 0000000..a244b8d --- /dev/null +++ b/utils/upsert-dir.js @@ -0,0 +1,23 @@ +import { existsSync, mkdirSync } from 'fs' +import { logError } from './loggers.js' + +/** + * Creates a directory if it does not exist. + * + * @param {string} pathLike + * @param {import('fs').MakeDirectoryOptions} opts + */ +const upsertDir = (pathLike, opts = {}) => { + try { + if (!existsSync(pathLike)) { + mkdirSync(pathLike, { recursive: true, ...opts }) + } + + return pathLike + } catch (error) { + logError(error) + return process.exit(1) + } +} + +export default upsertDir