npx-import
Advanced tools
Comparing version 0.0.1 to 0.0.2
declare type Logger = (message: string) => void; | ||
export declare function npxImport<T = unknown>(pkg: string | string[], logger?: Logger): Promise<T>; | ||
export declare function npxResolve(pkg: string): string; | ||
declare const INSTRUCTIONS: { | ||
@@ -4,0 +5,0 @@ npm: (packageName: string) => string; |
import semver from 'semver'; | ||
import path from 'path'; | ||
import { parse } from 'parse-package-name'; | ||
import { _import, _importRelative } from './utils.js'; | ||
import { _import, _importRelative, _resolve, _resolveRelative } from './utils.js'; | ||
import { execaCommand } from 'execa'; | ||
import validateNpmName from 'validate-npm-package-name'; | ||
const NOT_INSTALLED = Symbol(); | ||
const NOT_IMPORTABLE = Symbol(); | ||
const INSTALLED_LOCALLY = Symbol(); | ||
const INSTALL_CACHE = {}; | ||
export async function npxImport(pkg, logger = (message) => console.log(`[NPXI] ${message}`)) { | ||
const packages = await checkPackagesAvailableLocally(pkg); | ||
const missingPackages = Object.values(packages).filter((p) => p.imported === NOT_INSTALLED); | ||
const allPackages = Object.values(packages); | ||
const localPackages = allPackages.filter((p) => p.imported !== NOT_IMPORTABLE); | ||
const missingPackages = allPackages.filter((p) => p.imported === NOT_IMPORTABLE); | ||
if (missingPackages.length > 0) { | ||
@@ -20,3 +24,7 @@ logger(`${missingPackages.length > 1 | ||
packages[pkg.name].imported = await _importRelative(installDir, pkg.packageWithPath); | ||
INSTALL_CACHE[pkg.name] = installDir; | ||
} | ||
for (const pkg of localPackages) { | ||
INSTALL_CACHE[pkg.name] = INSTALLED_LOCALLY; | ||
} | ||
} | ||
@@ -32,6 +40,20 @@ catch (e) { | ||
} | ||
const results = allPackages.map((p) => p.imported); | ||
// If you pass in an array, you get an array back. | ||
const results = Object.values(packages).map((p) => p.imported); | ||
return Array.isArray(pkg) ? results : results[0]; | ||
} | ||
export function npxResolve(pkg) { | ||
const { name, path } = parse(pkg); | ||
const packageWithPath = [name, path].join(''); | ||
const cachedDir = INSTALL_CACHE[name]; | ||
if (!cachedDir) { | ||
throw new Error(`You must call npxImport for a package before calling npxResolve. Got: ${pkg}`); | ||
} | ||
else if (cachedDir === INSTALLED_LOCALLY) { | ||
return _resolve(packageWithPath); | ||
} | ||
else { | ||
return _resolveRelative(cachedDir, packageWithPath); | ||
} | ||
} | ||
async function checkPackagesAvailableLocally(pkg) { | ||
@@ -44,2 +66,3 @@ const packages = {}; | ||
const packageWithPath = [name, path].join(''); | ||
const imported = await tryImport(packageWithPath); | ||
packages[name] = { | ||
@@ -50,3 +73,4 @@ name, | ||
path, | ||
imported: await tryImport(packageWithPath), | ||
imported, | ||
local: imported !== NOT_IMPORTABLE, | ||
}; | ||
@@ -75,3 +99,3 @@ } | ||
catch (e) { | ||
return NOT_INSTALLED; | ||
return NOT_IMPORTABLE; | ||
} | ||
@@ -78,0 +102,0 @@ } |
export declare function _import(packageWithPath: string): Promise<any>; | ||
export declare function _importRelative(installDir: string, packageWithPath: string): Promise<any>; | ||
export declare function _resolve(packageWithPath: string): string; | ||
export declare function _resolveRelative(installDir: string, packageWithPath: string): string; |
@@ -9,1 +9,7 @@ /* The purpose of this file is to be mocked for testing. */ | ||
} | ||
export function _resolve(packageWithPath) { | ||
return require.resolve(packageWithPath); | ||
} | ||
export function _resolveRelative(installDir, packageWithPath) { | ||
return createRequire(installDir).resolve(packageWithPath); | ||
} |
{ | ||
"name": "npx-import", | ||
"version": "0.0.1", | ||
"version": "0.0.2", | ||
"description": "NPX import: dynamically install & import packages at runtime, using NPX.", | ||
@@ -5,0 +5,0 @@ "main": "lib/index.js", |
@@ -5,3 +5,3 @@ # 🧙♂️ `npx-import` 🧙♀️ | ||
<br/> | ||
[![](https://img.shields.io/badge/author-@glenmaddern-blue.svg?style=flat)](https://twitter.com/glenmaddern) ![npm](https://img.shields.io/npm/v/npx-import) ![GitHub last commit](https://img.shields.io/github/last-commit/geelen/npx-import) | ||
@@ -30,3 +30,3 @@ `npx-import` can be used as a drop-in replacement for [dynamic `import()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import): | ||
// Use npxImport to defer | ||
// Use npxImport to defer | ||
import { npxImport } from 'npx-import' | ||
@@ -66,10 +66,10 @@ | ||
* You either add `imagemagick-utils` & `chonk-pdf-boi` as dependencies, slowing down initial install. | ||
* The first time a user tries to export a PNG/PDF, you error out with instructions to install the relevant package and retry. | ||
* You pause, prompt the user for confirmation, then try to detect which package manager they're using and auto-install the dependency for them. | ||
- You either add `imagemagick-utils` & `chonk-pdf-boi` as dependencies, slowing down initial install. | ||
- The first time a user tries to export a PNG/PDF, you error out with instructions to install the relevant package and retry. | ||
- You pause, prompt the user for confirmation, then try to detect which package manager they're using and auto-install the dependency for them. | ||
The last of these generally works well but `npx-import` has slightly different properties: | ||
* The user doesn't need to be prompted—if the dependency can be sourced, installed & transparently included, the program doesn't need to be interrupted. | ||
* Your user's current project directory is never altered as a side-effect of running a program. | ||
- The user doesn't need to be prompted—if the dependency can be sourced, installed & transparently included, the program doesn't need to be interrupted. | ||
- Your user's current project directory is never altered as a side-effect of running a program. | ||
@@ -96,8 +96,8 @@ Most importantly, though **it's compatible with `npx`!** For example, `npx some-cli --help` can be super fast but `npx some-cli export --type=pdf` can transparently download the required dependencies during execution. It's super neat! | ||
import { npxImport } from 'npx-import' | ||
import type { BigDep } from 'big-dep' | ||
import type BigDep from 'big-dep' | ||
const bigDep = await npxImport<BigDep>('big-dep') | ||
const { default: bigDep } = await npxImport<{ default: BigDep }>('big-dep') | ||
``` | ||
## Configuration | ||
## API | ||
@@ -125,9 +125,22 @@ Since package versions are no longer tracked in your `package.json`, we recommend being explicit: | ||
If you ever need the equivalent for `require.resolve` for a package, use `npxResolve`: | ||
```ts | ||
export function getSQLiteNativeBindingLocation() { | ||
return path.resolve( | ||
path.dirname(npxResolve("better-sqlite3")), | ||
"../build/Release/better_sqlite3.node" | ||
); | ||
} | ||
``` | ||
Note, `npxResolve` requires that you'd previously called `npxImport` for the same package. | ||
## FAQ | ||
### Isn't this, like, a heroically bad idea? | ||
### 🤔 Isn't this, like, a heroically bad idea? | ||
Nah it's good actually. | ||
### No but seriously, isn't using `npx` a big security hole? | ||
### 🤨 No but seriously, isn't using `npx` a big security hole? | ||
@@ -145,9 +158,9 @@ Initially, `npx` didn't prompt before downloading and executing a package, which was _definitely_ a security risk. But that's been [fixed since version 7](https://github.com/npm/npx/issues/9#issuecomment-786940691). Now, if you're intending to write `npx prettier` to format your code and accidentally type `npx prettomghackmycomputerpls`, you'll get a helpful prompt: | ||
### But hang on, you're never prompting the user to confirm!? | ||
### 😠 But hang on, you're never prompting the user to confirm! | ||
Ah yes, that seems to go against the previous point. But `npx-import` isn't being triggered from a potentially clumsy human on a keyboard, it's running inside some source code you've (by definition) already authorised to run on your machine. | ||
`npx-import` is an alternative to publishing these as normal dependencies of your project and having your users download them at install time. Users aren't prompted to approve every transitive dependency of the things they install/run, so `npx-import` doesn't either. | ||
`npx-import` is an alternative to publishing these as normal dependencies of your project and having your users download them at install time. `npm install` doesn't prompt the user to approve every transitive dependency of what's being installed/run, so `npx-import` doesn't either. | ||
### What if the user has already installed the dependency somewhere? | ||
### 🧐 What if the user has already installed the dependency somewhere? | ||
@@ -158,7 +171,7 @@ Then `npxImport` short-circuits, returning the local version without logging anything out. This is what the user is instructed to do to "skip this step in future". In other words, `npxImport()` first tries to call your native `import()`, and only does anything if that fails. | ||
### What about multiple projects? Won't you get conflicting versions? | ||
### 😵💫 What about multiple projects? Won't you get conflicting versions? | ||
As it turns out, no! While I wasn't paying attention, `npx` got really smart! To understand why, we need to look at how `npx` works: | ||
For starters, `npx some-pkg` is a shorthand for `npx -p some-pkg <command>`, where `<command>` is whatever `bin` that `some-pkg` declares. Often, the `<command>` and the package name are the same (e.g. `npx prettier`), but it's the `bin` field inside the package that's really being used. Otherwise, scoped packages like `npx @some-org/cli-tool` would never work. If there's no `bin` field declared (e.g. for `chokidar`, you need `npx chokidar-cli`), or if there's more than one (e.g. for `typescript`, you need `npx -p typescript tsc`), you have to use the expanded form. | ||
For starters, `npx some-pkg` is a shorthand for `npx -p some-pkg <command>`, where `<command>` is whatever `bin` that `some-pkg` declares. Often, the `<command>` and the package name are the same (e.g. `npx prettier`), but it's the `bin` field inside the package that's really being used. Otherwise, scoped packages (like `npx @11ty/eleventy`) would never work. If there's no `bin` field declared (e.g. for `chokidar`, you need `npx chokidar-cli`), or if there's more than one (e.g. for `typescript`, you need `npx -p typescript tsc`), you have to use the expanded form. | ||
@@ -248,3 +261,3 @@ But there's no requirement that `<command>` is a `bin` inside the package at all! It can be any command (at least for `npx`, `pnpm dlx` and `yarn dlx` have different restrictions), for example, we can inject a `node -e` command and start to learn about what's going on: | ||
### But what about transitive deps? Won't you get duplication? | ||
### 😐 But what about transitive deps? Won't you get duplication? | ||
@@ -255,3 +268,3 @@ Sadly, yes. If both your package `main-pkg` and `util-a` depend on `util-b`, then calling `npxImport('util-a')` from within `main-pkg` will create a new directory with a second copy of `util-b`. If there are globals in that package, or if the version specifiers are slightly different, you could potentially have problems. | ||
### What about version mismatch with local files? | ||
### 🫤 What about version mismatch with local files? | ||
@@ -262,8 +275,8 @@ If a user has `pkg-a` version `1.0.0` installed, but one of their packages calls `npxImport('pkg-a@^2.0.0')`, `npxImport` isn't smart enough ([yet](https://github.com/geelen/npx-import/issues/3)) to know that the local version of `pkg-a` doesn't match the version range specified (since it's using native `import()` under the hood). Without `npxImport`, the `npm install` step would have had a chance to bump the installed version of `pkg-a` to meet the requirements of _all_ packages being used, but we're bypassing that. | ||
### What kind of packages would you use this for? | ||
### 🫠 What kind of packages would you use this for? | ||
* Anything with native extensions needing building (do that when you need it) | ||
* Packages with large downloads (e.g. puppeteer, sqlite-node) | ||
* CLI packages that want to make `npx my-cli --help` or `npx my-cli init` really fast and dependency-free, but also allow `npx my-cli <cmd>` to pull in arbitrary deps on-demand, without forcing the user to stop, create a local directory, and install dev dependencies. | ||
* Anything already making heavy use of `npx`. You're in the jungle already, baby. | ||
- Anything with native extensions needing building (do that when you need it) | ||
- Packages with large downloads (e.g. puppeteer, sqlite-node) | ||
- CLI packages that want to make `npx my-cli --help` or `npx my-cli init` really fast and dependency-free, but also allow `npx my-cli <cmd>` to pull in arbitrary deps on-demand, without forcing the user to stop, create a local directory, and install dev dependencies. | ||
- Anything already making heavy use of `npx`. You're in the jungle already, baby. | ||
@@ -270,0 +283,0 @@ --- |
import semver from 'semver' | ||
import path from 'path' | ||
import { parse } from 'parse-package-name' | ||
import { _import, _importRelative } from './utils.js' | ||
import { _import, _importRelative, _resolve, _resolveRelative } from './utils.js' | ||
import { execaCommand } from 'execa' | ||
@@ -15,6 +15,9 @@ import validateNpmName from 'validate-npm-package-name' | ||
path: string | ||
imported: typeof NOT_INSTALLED | any | ||
imported: typeof NOT_IMPORTABLE | any | ||
local: boolean | ||
} | ||
const NOT_INSTALLED = Symbol() | ||
const NOT_IMPORTABLE = Symbol() | ||
const INSTALLED_LOCALLY = Symbol() | ||
const INSTALL_CACHE: Record<string, string | typeof INSTALLED_LOCALLY> = {} | ||
@@ -26,3 +29,6 @@ export async function npxImport<T = unknown>( | ||
const packages = await checkPackagesAvailableLocally(pkg) | ||
const missingPackages = Object.values(packages).filter((p) => p.imported === NOT_INSTALLED) | ||
const allPackages = Object.values(packages) | ||
const localPackages = allPackages.filter((p) => p.imported !== NOT_IMPORTABLE) | ||
const missingPackages = allPackages.filter((p) => p.imported === NOT_IMPORTABLE) | ||
if (missingPackages.length > 0) { | ||
@@ -41,3 +47,7 @@ logger( | ||
packages[pkg.name].imported = await _importRelative(installDir, pkg.packageWithPath) | ||
INSTALL_CACHE[pkg.name] = installDir | ||
} | ||
for (const pkg of localPackages) { | ||
INSTALL_CACHE[pkg.name] = INSTALLED_LOCALLY | ||
} | ||
} catch (e) { | ||
@@ -55,7 +65,20 @@ throw new Error( | ||
const results = allPackages.map((p) => p.imported) | ||
// If you pass in an array, you get an array back. | ||
const results = Object.values(packages).map((p) => p.imported) | ||
return Array.isArray(pkg) ? results : results[0] | ||
} | ||
export function npxResolve(pkg: string): string { | ||
const { name, path } = parse(pkg) | ||
const packageWithPath = [name, path].join('') | ||
const cachedDir = INSTALL_CACHE[name] | ||
if (!cachedDir) { | ||
throw new Error(`You must call npxImport for a package before calling npxResolve. Got: ${pkg}`) | ||
} else if (cachedDir === INSTALLED_LOCALLY) { | ||
return _resolve(packageWithPath) | ||
} else { | ||
return _resolveRelative(cachedDir, packageWithPath) | ||
} | ||
} | ||
async function checkPackagesAvailableLocally(pkg: string | string[]) { | ||
@@ -71,2 +94,3 @@ const packages: Record<string, Package> = {} | ||
const packageWithPath = [name, path].join('') | ||
const imported = await tryImport(packageWithPath) | ||
packages[name] = { | ||
@@ -77,3 +101,4 @@ name, | ||
path, | ||
imported: await tryImport(packageWithPath), | ||
imported, | ||
local: imported !== NOT_IMPORTABLE, | ||
} | ||
@@ -107,3 +132,3 @@ } | ||
} catch (e) { | ||
return NOT_INSTALLED | ||
return NOT_IMPORTABLE | ||
} | ||
@@ -110,0 +135,0 @@ } |
@@ -11,1 +11,9 @@ /* The purpose of this file is to be mocked for testing. */ | ||
} | ||
export function _resolve(packageWithPath: string) { | ||
return require.resolve(packageWithPath) | ||
} | ||
export function _resolveRelative(installDir: string, packageWithPath: string) { | ||
return createRequire(installDir).resolve(packageWithPath) | ||
} |
@@ -1,2 +0,2 @@ | ||
import {npxImport} from '../lib/index.js' | ||
import { npxImport, npxResolve } from '../lib/index.js' | ||
@@ -10,2 +10,4 @@ try { | ||
} | ||
console.log({ location: await npxResolve('left-pad@3.4.0') }) | ||
console.log(`Done!`) |
@@ -8,2 +8,4 @@ import { afterEach, describe, expect, test, vi } from 'vitest' | ||
_import, | ||
_resolve, | ||
_resolveRelative, | ||
randomString, | ||
@@ -16,3 +18,3 @@ runPostAssertions, | ||
} from './utils' | ||
import { npxImport } from '../lib' | ||
import { npxImport, npxResolve } from '../lib' | ||
@@ -23,2 +25,4 @@ vi.mock('../lib/utils', () => { | ||
_importRelative: vi.fn(), | ||
_resolve: vi.fn(), | ||
_resolveRelative: vi.fn(), | ||
} | ||
@@ -331,1 +335,35 @@ }) | ||
}) | ||
describe(`npxResolve`, () => { | ||
afterEach(() => { | ||
runPostAssertions() | ||
vi.clearAllMocks() | ||
}) | ||
test(`Should return one local one new directory`, async () => { | ||
const npxDirectoryHash = randomString(12) | ||
const basePath = `/Users/glen/.npm/_npx/${npxDirectoryHash}/node_modules` | ||
_import.mockResolvedValueOnce({}) // pkg-a | ||
_import.mockRejectedValueOnce('not-found') // pkg-b | ||
expectExecaCommand('npx --version').returning({ stdout: '8.1.2' }) | ||
expectExecaCommand(`npx -y -p pkg-b@latest node -e 'console.log(process.env.PATH)'`, { | ||
shell: true, | ||
}).returning({ stdout: getNpxPath(npxDirectoryHash) }) | ||
expectRelativeImport(basePath, 'pkg-b').returning({ name: 'pkg-b', bar: 2, local: false }) | ||
await npxImport(['pkg-a', 'pkg-b'], () => {}) | ||
expect(_import).toHaveBeenCalledTimes(2) | ||
_resolve.mockReturnValueOnce('/Users/glen/src/npx-import/pkg-a') | ||
const localPath = npxResolve('pkg-a') | ||
expect(localPath).toBe('/Users/glen/src/npx-import/pkg-a') | ||
expect(_resolve).toHaveBeenLastCalledWith('pkg-a') | ||
_resolveRelative.mockReturnValueOnce(`${basePath}/pkg-b`) | ||
const tempPath = npxResolve('pkg-b') | ||
expect(tempPath).toBe(`${basePath}/pkg-b`) | ||
expect(_resolveRelative).toHaveBeenLastCalledWith(basePath, 'pkg-b') | ||
}) | ||
}) |
@@ -8,9 +8,11 @@ import { expect, MockedFunction } from 'vitest' | ||
const { _import, _importRelative } = utils as unknown as { | ||
const { _import, _importRelative, _resolve, _resolveRelative } = utils as unknown as { | ||
_import: MockedFunction<typeof utils._import> | ||
_importRelative: MockedFunction<typeof utils._importRelative> | ||
_resolve: MockedFunction<typeof utils._resolve> | ||
_resolveRelative: MockedFunction<typeof utils._resolveRelative> | ||
} | ||
const execaCommand = _execaCommand as MockedFunction<any> | ||
export { _import, _importRelative, execaCommand } | ||
const MOCKS = { _import, _importRelative, execaCommand } | ||
export { _import, _importRelative, execaCommand, _resolve, _resolveRelative } | ||
const MOCKS = { _import, _importRelative, execaCommand, _resolve, _resolveRelative } | ||
@@ -17,0 +19,0 @@ let MOCK_COUNTERS: { [key in keyof typeof MOCKS]?: number } = {} |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Dynamic require
Supply chain riskDynamic require can indicate the package is performing dangerous or unsafe dynamic code execution.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
55062
847
276
5