npx-import
Advanced tools
Comparing version 0.0.0 to 0.0.1
import semver from 'semver'; | ||
import path from 'path'; | ||
import { parse } from 'parse-package-name'; | ||
import { _import, _importRelative } from './utils'; | ||
import { _import, _importRelative } from './utils.js'; | ||
import { execaCommand } from 'execa'; | ||
import validateNpmName from 'validate-npm-package-name'; | ||
const NOT_INSTALLED = Symbol(); | ||
export async function npxImport(pkg, logger = (message) => console.log(`[IOD] ${message}`)) { | ||
export async function npxImport(pkg, logger = (message) => console.log(`[NPXI] ${message}`)) { | ||
const packages = await checkPackagesAvailableLocally(pkg); | ||
@@ -22,3 +23,3 @@ const missingPackages = Object.values(packages).filter((p) => p.imported === NOT_INSTALLED); | ||
catch (e) { | ||
throw new Error(`IOD (Import On-Demand) failed for ${missingPackages | ||
throw new Error(`npx-import failed for ${missingPackages | ||
.map((p) => p.packageWithPath) | ||
@@ -38,5 +39,5 @@ .join(',')} with message:\n ${e.message}\n\n` + | ||
for (const p of Array.isArray(pkg) ? pkg : [pkg]) { | ||
const { name, version, path } = parse(p); | ||
const { name, version, path } = parseAndValidate(p); | ||
if (packages[name]) | ||
throw `npx-import cannot import the same package twice! Got: ${p} but already saw ${name} earlier!`; | ||
throw new Error(`npx-import cannot import the same package twice! Got: '${p}' but already saw '${name}' earlier!`); | ||
const packageWithPath = [name, path].join(''); | ||
@@ -53,2 +54,16 @@ packages[name] = { | ||
} | ||
function parseAndValidate(p) { | ||
if (p.match(/^[.\/]/)) { | ||
throw new Error(`npx-import can only import packages, not relative paths: got ${p}`); | ||
} | ||
const { name, version, path } = parse(p); | ||
const validation = validateNpmName(name); | ||
if (!validation.validForNewPackages) { | ||
if (validation.warnings?.some((w) => w.match(/is a core module name/))) | ||
throw new Error(`npx-import can only import NPM packages, got core module '${name}' from '${p}'`); | ||
else | ||
throw new Error(`npx-import can't import invalid package name: parsed name '${name}' from '${p}'`); | ||
} | ||
return { name, version, path }; | ||
} | ||
async function tryImport(packageWithPath) { | ||
@@ -73,3 +88,3 @@ try { | ||
async function installAndReturnDir(packages, logger) { | ||
const installPackage = `npx -y ${packages.map((p) => `-p ${p.name}@${p.version}`).join(' ')}`; | ||
const installPackage = `npx -y ${packages.map((p) => `-p ${formatForCLI(p)}`).join(' ')}`; | ||
logger(`Installing... (${installPackage})`); | ||
@@ -103,3 +118,3 @@ const emitPath = `node -e 'console.log(process.env.PATH)'`; | ||
function installInstructions(packages) { | ||
return INSTRUCTIONS[getPackageManager()](packages.map((p) => `${p.name}@${p.version}`).join(' ')); | ||
return INSTRUCTIONS[getPackageManager()](packages.map(formatForCLI).join(' ')); | ||
} | ||
@@ -132,1 +147,6 @@ export function getPackageManager() { | ||
} | ||
// If the version contains special chars, wrap in '' | ||
const formatForCLI = (p) => { | ||
const unescaped = `${p.name}@${p.version}`; | ||
return unescaped.match(/[<>*]/) ? `'${unescaped}'` : unescaped; | ||
}; |
{ | ||
"name": "npx-import", | ||
"version": "0.0.0", | ||
"version": "0.0.1", | ||
"description": "NPX import: dynamically install & import packages at runtime, using NPX.", | ||
@@ -10,2 +10,3 @@ "main": "lib/index.js", | ||
"dev": "tsc --watch", | ||
"go": "node --experimental-vm-modules test/go.js", | ||
"test": "pnpm build && vitest", | ||
@@ -21,2 +22,3 @@ "test:watch": "vitest --watch" | ||
"@types/semver": "^7.3.10", | ||
"@types/validate-npm-package-name": "^4.0.0", | ||
"prettier": "^2.7.1", | ||
@@ -29,4 +31,5 @@ "typescript": "^4.7.4", | ||
"parse-package-name": "^1.0.0", | ||
"semver": "^7.3.7" | ||
"semver": "^7.3.7", | ||
"validate-npm-package-name": "^4.0.0" | ||
} | ||
} |
264
README.md
@@ -1,62 +0,262 @@ | ||
# NPX Import | ||
# 🧙♂️ `npx-import` 🧙♀️ | ||
**Use `npx` to defer installation of dependencies to runtime** | ||
### Runtime dependencies, installed _as if by magic_ ✨ | ||
## Installation | ||
<br/> | ||
`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): | ||
```ts | ||
import { npxImport } from 'npx-import' | ||
// If big-dep isn't installed locally, npxImport will try | ||
// to download, install & load it, completely seamlessly. | ||
const dependency = await npxImport('big-dep') | ||
``` | ||
npm install --save iod | ||
pnpm add -P iod | ||
yarn add iod | ||
``` | ||
It's exactly like [`npx`](https://docs.npmjs.com/cli/v8/commands/npx), but for `import()`! <sub><sub><sup>(hence the name)</sup></sub></sub> | ||
Is this a good idea? See [FAQ](#faq) below. | ||
## Usage | ||
Anywhere in your app, you can do: | ||
`npx-import` is ideal for deferring installation for dependencies that are unexpectedly large, require native compilation, or not used very often (or some combination thereof), for example: | ||
```ts | ||
import { importOnDemand } from 'iod' | ||
// Statically import small/common deps as normal | ||
import textRenderer from 'tiny-text-renderer' | ||
if (process.stdout.isTTY) { | ||
console.log(`We're in a real terminal, let's load left-pad!`) | ||
// Use it as a direct replacement for .import() (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import) | ||
const leftPad = await importOnDemand('left-pad') | ||
console.log(leftPad(`Right aligned text!`, 80)) | ||
// Use npxImport to defer | ||
import { npxImport } from 'npx-import' | ||
export async function writeToFile(report: Report, filename: string) { | ||
if (filename.endsWith('.png')) { | ||
console.log(`This is a PNG! We'll have to compile imagemagick!`) | ||
const magick = await npxImport('imagemagick-utils@^1.1.0') | ||
await magick.renderToPNG(report, filename) | ||
} else if (filename.endsWith('.pdf')) { | ||
console.log(`Argh, a PDF!? Go make a cuppa, this'll take a while...`) | ||
const pdfBoi = await npxImport('chonk-pdf-boi@3.1.4') | ||
await pdfBoi.generate(report, filename) | ||
} else { | ||
console.log(`Writing to ${filename}...`) | ||
await textRenderer.write(report, filename) | ||
} | ||
console.log(`Done!`) | ||
} | ||
``` | ||
``` | ||
Produces: | ||
When run, `npx-import` will log out some explanation, as well as instructions for installing the dependency locally & skipping this step in future: | ||
``` | ||
We're in a real terminal, let's load left-pad! | ||
[IOD] left-pad not available locally. Attempting to use npx to install temporarily. | ||
[IOD] Installing... (npx -y -p left-pad@latest) | ||
[IOD] Installed into /Users/glen/.npm/_npx/93d8cd1db3c1662d/node_modules. | ||
[IOD] To skip this step in future, run: pnpm add -D left-pad@latest | ||
Right aligned text! | ||
❯ node ./index.js --filename=image.png | ||
This is a PNG! We'll have to compile imagemagick! | ||
[NPXI] imagemagick-utils not available locally. Attempting to use npx to install temporarily. | ||
[NPXI] Installing... (npx -y -p imagemagick-utils@^1.1.0) | ||
[NPXI] Installed into /Users/glen/.npm/_npx/8cac855b1579fd07/node_modules. | ||
[NPXI] To skip this step in future, run: pnpm add -D imagemagick-utils@^1.1.0 | ||
Done! | ||
``` | ||
For some types of dependencies, this is a much better UX than the alternatives: | ||
* 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. | ||
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! | ||
## Installation | ||
``` | ||
npm install --save npx-import | ||
pnpm add -P npx-import | ||
yarn add npx-import | ||
``` | ||
## Typescript | ||
Just like `import()`, the return type default to `any`. But you can import the types of a devDependency without any consumers of your package needing to download it at installation time. | ||
``` | ||
pnpm add -D big-dep | ||
``` | ||
```ts | ||
import { npxImport } from 'npx-import' | ||
import type { BigDep } from 'big-dep' | ||
const bigDep = await npxImport<BigDep>('big-dep') | ||
``` | ||
## Configuration | ||
Since package versions are no longer tracked in your `package.json`, we recommend being explicit | ||
Since package versions are no longer tracked in your `package.json`, we recommend being explicit: | ||
```ts | ||
const lazyDep = await importOnDemand('left-pad@1.3.0') | ||
const lazyDep = await npxImport('left-pad@1.3.0') | ||
``` | ||
IOD also takes a third argument, which lets you customise, or silence, the log output. Each line that would normally be printed is passed to the logger function: | ||
Any package specifier that's valid in `package.json` will work here: e.g. `^1.0.0`, `~2.3.0`, `>4.0.0` | ||
You can also install multiple packages at once: | ||
```ts | ||
if (process.stdout.isTTY) { | ||
console.log(`We're in a real terminal, let's load left-pad!`) | ||
const leftPad = await importOnDemand('left-pad', '1.3.0', () => process.stdout.write('.')) | ||
console.log(leftPad(`Right aligned text!`, 42)) | ||
const [depA, depB] = await npxImport(['dep-a@7.8.2', 'dep-b@7.8.2']) | ||
``` | ||
`npx-import` also takes a third argument, which lets you customise, or silence, the log output. Each line that would normally be printed is passed to the logger function: | ||
```ts | ||
const grayLog = (line: string) => console.log(chalk.gray(line)) | ||
const [depA, depB] = await npxImport(['dep-a@7.8.2', 'dep-b@7.8.2'], grayLog) | ||
``` | ||
## FAQ | ||
### Isn't this, like, a heroically bad idea? | ||
Nah it's good actually. | ||
### No but seriously, isn't using `npx` a big security hole? | ||
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: | ||
``` | ||
❯ npx prettier@latest | ||
Need to install the following packages: | ||
prettomghackmycomputerpls@6.6.6 | ||
Ok to proceed? (y) | ||
``` | ||
This gives the user a chance to see their mistake and prevent being hacked to bits. | ||
### 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. | ||
### What if the user has already installed the dependency somewhere? | ||
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. | ||
Note that this also works for multiple dependencies, `npxImport(['pkg-a', 'pkg-b', 'pkg-c'])` will only fetch & install those that are missing. | ||
### 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. | ||
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: | ||
``` | ||
❯ npx -y -p is-odd node -e 'console.log(process.env.PATH.split(":"))' | grep .npm/_npx | ||
'/Users/glen/.npm/_npx/e1b5bd0eb9f99fbc/node_modules/.bin', | ||
``` | ||
Using `process.env.PATH` and searching for `.npm/_npx` is, on OSX with NPX v8+, a reliable way to find out where `npx` is installing these temporary packages. Let's look inside: | ||
``` | ||
❯ ll2 /Users/glen/.npm/_npx/e1b5bd0eb9f99fbc/ | ||
drwxr-xr-x - glen 4 Aug 11:07 /Users/glen/.npm/_npx/e1b5bd0eb9f99fbc | ||
drwxr-xr-x - glen 4 Aug 11:07 ├── node_modules | ||
.rw-r--r-- 780 glen 4 Aug 11:07 │ ├── .package-lock.json | ||
drwxr-xr-x - glen 4 Aug 11:07 │ ├── is-number | ||
drwxr-xr-x - glen 4 Aug 11:07 │ └── is-odd | ||
.rw-r--r-- 1.4k glen 4 Aug 11:07 ├── package-lock.json | ||
.rw-r--r-- 51 glen 4 Aug 11:07 └── package.json | ||
❯ cat /Users/glen/.npm/_npx/e1b5bd0eb9f99fbc/package.json | ||
{ | ||
"dependencies": { | ||
"is-odd": "^3.0.1" | ||
} | ||
} | ||
``` | ||
That looks like a pretty normal project directory to me! | ||
> Aside, `ll2` is my super rad alias for `exa --icons -laTL 2`. See [exa](https://github.com/ogham/exa). | ||
Now, the crucial bit: **every time `npx` runs for some unique set of packages it creates a new directory**. That goes for installing multiple deps at once but also for different named/pinned versions/tags for individual packages: | ||
``` | ||
We're in a real terminal, let's load left-pad! | ||
.... Right aligned text! | ||
❯ export LOG_NPX_DIR="node -e 'console.log(process.env.PATH.split(\":\").filter(p => p.match(/\.npm\/_npx/)))'" | ||
❯ npx -y -p is-odd $LOG_NPX_DIR | ||
[ '/Users/glen/.npm/_npx/e1b5bd0eb9f99fbc/node_modules/.bin' ] | ||
❯ npx -y -p is-odd@latest $LOG_NPX_DIR | ||
[ '/Users/glen/.npm/_npx/ecc6e2260c717fec/node_modules/.bin' ] | ||
❯ npx -y -p is-odd@3.0.1 $LOG_NPX_DIR | ||
[ '/Users/glen/.npm/_npx/c41e9ab9d1d9c43f/node_modules/.bin' ] | ||
❯ npx -y -p is-odd@\^3.0.1 $LOG_NPX_DIR | ||
[ '/Users/glen/.npm/_npx/e86896689f5aebbb/node_modules/.bin' ] | ||
``` | ||
## Rationale | ||
Note that **every one of these commands downloaded the same version of `is-odd`**, but because they were referenced using different identifiers, `_` vs `latest` vs `3.0.1` vs `>3.0.1`, `npx` played it safe and made a new temporary directory. | ||
For multiple packages, the same rule applies, although order is not important: | ||
``` | ||
❯ npx -y -p is-odd -p is-even $LOG_NPX_DIR | ||
[ '/Users/glen/.npm/_npx/f9af4fded130fd33/node_modules/.bin' ] | ||
❯ npx -y -p is-even -p is-odd $LOG_NPX_DIR | ||
[ '/Users/glen/.npm/_npx/f9af4fded130fd33/node_modules/.bin' ] | ||
❯ ll2 /Users/glen/.npm/_npx/f9af4fded130fd33 | ||
drwxr-xr-x - glen 4 Aug 11:37 /Users/glen/.npm/_npx/f9af4fded130fd33 | ||
drwxr-xr-x - glen 4 Aug 11:37 ├── node_modules | ||
.rw-r--r-- 2.6k glen 4 Aug 11:37 │ ├── .package-lock.json | ||
drwxr-xr-x - glen 4 Aug 11:37 │ ├── is-buffer | ||
drwxr-xr-x - glen 4 Aug 11:37 │ ├── is-even | ||
drwxr-xr-x - glen 4 Aug 11:37 │ ├── is-number | ||
drwxr-xr-x - glen 4 Aug 11:37 │ ├── is-odd | ||
drwxr-xr-x - glen 4 Aug 11:37 │ └── kind-of | ||
.rw-r--r-- 4.8k glen 4 Aug 11:37 ├── package-lock.json | ||
.rw-r--r-- 76 glen 4 Aug 11:37 └── package.json | ||
❯ cat /Users/glen/.npm/_npx/f9af4fded130fd33/package.json | ||
{ | ||
"dependencies": { | ||
"is-even": "^1.0.0", | ||
"is-odd": "^3.0.1" | ||
} | ||
} | ||
``` | ||
So `npx` is doing exactly the same as an `npm install`, with a `package.json`, `package-lock.json`, `node_modules` etc. It's just dynamically creating directories based on some hash of its inputs. It's super clever! | ||
### But what about transitive deps? Won't you get duplication? | ||
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. | ||
It's probably possible to [detect this in future](https://github.com/geelen/npx-import/issues/2) and warn/error out. But for now, I recommend using `npxImport` for mostly self-contained dependencies. | ||
### What about version mismatch with local files? | ||
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. | ||
This will be fixed in a future version. | ||
### 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. | ||
--- | ||
Built with <3 during a massive yak shave by Glen Maddern. |
import semver from 'semver' | ||
import path from 'path' | ||
import { parse } from 'parse-package-name' | ||
import { _import, _importRelative } from './utils' | ||
import { _import, _importRelative } from './utils.js' | ||
import { execaCommand } from 'execa' | ||
import validateNpmName from 'validate-npm-package-name' | ||
@@ -21,3 +22,3 @@ type Logger = (message: string) => void | ||
pkg: string | string[], | ||
logger: Logger = (message: string) => console.log(`[IOD] ${message}`) | ||
logger: Logger = (message: string) => console.log(`[NPXI] ${message}`) | ||
): Promise<T> { | ||
@@ -42,3 +43,3 @@ const packages = await checkPackagesAvailableLocally(pkg) | ||
throw new Error( | ||
`IOD (Import On-Demand) failed for ${missingPackages | ||
`npx-import failed for ${missingPackages | ||
.map((p) => p.packageWithPath) | ||
@@ -54,3 +55,3 @@ .join(',')} with message:\n ${e.message}\n\n` + | ||
// If you pass in an array, you get an array back. | ||
const results = Object.values(packages).map((p) => p.imported); | ||
const results = Object.values(packages).map((p) => p.imported) | ||
return Array.isArray(pkg) ? results : results[0] | ||
@@ -63,5 +64,7 @@ } | ||
for (const p of Array.isArray(pkg) ? pkg : [pkg]) { | ||
const { name, version, path } = parse(p) | ||
const { name, version, path } = parseAndValidate(p) | ||
if (packages[name]) | ||
throw `npx-import cannot import the same package twice! Got: ${p} but already saw ${name} earlier!` | ||
throw new Error( | ||
`npx-import cannot import the same package twice! Got: '${p}' but already saw '${name}' earlier!` | ||
) | ||
const packageWithPath = [name, path].join('') | ||
@@ -79,2 +82,21 @@ packages[name] = { | ||
function parseAndValidate(p: string) { | ||
if (p.match(/^[.\/]/)) { | ||
throw new Error(`npx-import can only import packages, not relative paths: got ${p}`) | ||
} | ||
const { name, version, path } = parse(p) | ||
const validation = validateNpmName(name) | ||
if (!validation.validForNewPackages) { | ||
if (validation.warnings?.some((w) => w.match(/is a core module name/))) | ||
throw new Error( | ||
`npx-import can only import NPM packages, got core module '${name}' from '${p}'` | ||
) | ||
else | ||
throw new Error( | ||
`npx-import can't import invalid package name: parsed name '${name}' from '${p}'` | ||
) | ||
} | ||
return { name, version, path } | ||
} | ||
async function tryImport(packageWithPath: string) { | ||
@@ -101,3 +123,3 @@ try { | ||
async function installAndReturnDir(packages: Package[], logger: Logger) { | ||
const installPackage = `npx -y ${packages.map((p) => `-p ${p.name}@${p.version}`).join(' ')}` | ||
const installPackage = `npx -y ${packages.map((p) => `-p ${formatForCLI(p)}`).join(' ')}` | ||
logger(`Installing... (${installPackage})`) | ||
@@ -144,3 +166,3 @@ const emitPath = `node -e 'console.log(process.env.PATH)'` | ||
function installInstructions(packages: Package[]) { | ||
return INSTRUCTIONS[getPackageManager()](packages.map((p) => `${p.name}@${p.version}`).join(' ')) | ||
return INSTRUCTIONS[getPackageManager()](packages.map(formatForCLI).join(' ')) | ||
} | ||
@@ -170,1 +192,7 @@ | ||
} | ||
// If the version contains special chars, wrap in '' | ||
const formatForCLI = (p) => { | ||
const unescaped = `${p.name}@${p.version}` | ||
return unescaped.match(/[<>*]/) ? `'${unescaped}'` : unescaped | ||
} |
@@ -13,2 +13,3 @@ import { afterEach, describe, expect, test, vi } from 'vitest' | ||
expectRelativeImport, | ||
pkgParseFailed, | ||
} from './utils' | ||
@@ -44,3 +45,3 @@ import { npxImport } from '../lib' | ||
test(`should ignore versions (for now)`, async () => { | ||
test(`should ignore versions when trying to import locally (for now)`, async () => { | ||
await npxImportLocalPackage('fake-library@1.2.3') | ||
@@ -53,3 +54,3 @@ expect(_import).toHaveBeenCalledWith('fake-library') | ||
test(`should ignore tags`, async () => { | ||
test(`should ignore tags when trying to import locally`, async () => { | ||
await npxImportLocalPackage('fake-library@beta') | ||
@@ -80,2 +81,64 @@ expect(_import).toHaveBeenCalledWith('fake-library') | ||
describe('failure cases', () => { | ||
test(`Should fail for relative paths`, async () => { | ||
await pkgParseFailed( | ||
'./local-dep/index.js', | ||
`npx-import can only import packages, not relative paths: got ./local-dep/index.js` | ||
) | ||
await pkgParseFailed( | ||
'../local-dep/index.js', | ||
`npx-import can only import packages, not relative paths: got ../local-dep/index.js` | ||
) | ||
await pkgParseFailed( | ||
'/local-dep/index.js', | ||
`npx-import can only import packages, not relative paths: got /local-dep/index.js` | ||
) | ||
}) | ||
test(`Should fail for invalid package names`, async () => { | ||
await pkgParseFailed( | ||
'excited!', | ||
`npx-import can't import invalid package name: parsed name 'excited!' from 'excited!'` | ||
) | ||
await pkgParseFailed( | ||
' leading-space:and:weirdchars', | ||
`npx-import can't import invalid package name: parsed name ' leading-space:and:weirdchars' from ' leading-space:and:weirdchars'` | ||
) | ||
await pkgParseFailed( | ||
'@npm-zors/money!time.js', | ||
`npx-import can't import invalid package name: parsed name '@npm-zors/money!time.js' from '@npm-zors/money!time.js'` | ||
) | ||
await pkgParseFailed( | ||
'fs', | ||
`npx-import can only import NPM packages, got core module 'fs' from 'fs'` | ||
) | ||
await pkgParseFailed( | ||
'fs@latest', | ||
`npx-import can only import NPM packages, got core module 'fs' from 'fs@latest'` | ||
) | ||
await pkgParseFailed( | ||
'fs/promises', | ||
`npx-import can only import NPM packages, got core module 'fs' from 'fs/promises'` | ||
) | ||
}) | ||
test(`Should fail for the same package passed twice`, async () => { | ||
await npxImportFailed( | ||
['pkg-a', 'pkg-a'], | ||
`npx-import cannot import the same package twice! Got: 'pkg-a' but already saw 'pkg-a' earlier!` | ||
) | ||
await npxImportFailed( | ||
['pkg-a@latest', 'pkg-a'], | ||
`npx-import cannot import the same package twice! Got: 'pkg-a' but already saw 'pkg-a' earlier!` | ||
) | ||
await npxImportFailed( | ||
['pkg-a', 'pkg-a@latest'], | ||
`npx-import cannot import the same package twice! Got: 'pkg-a@latest' but already saw 'pkg-a' earlier!` | ||
) | ||
// Arguably, we could make this one work in future. | ||
await npxImportFailed( | ||
['pkg-a/path.js', 'pkg-a/other.js'], | ||
`npx-import cannot import the same package twice! Got: 'pkg-a/other.js' but already saw 'pkg-a' earlier!` | ||
) | ||
}) | ||
test(`Should fail if NPX can't be found`, async () => { | ||
@@ -102,3 +165,3 @@ expectExecaCommand('npx --version').returning({ failed: true }) | ||
expectExecaCommand( | ||
`npx -y -p broken-install@latest node -e 'console.log(process.env.PATH)'`, | ||
`npx -y -p broken-install@^2.0.0 node -e 'console.log(process.env.PATH)'`, | ||
{ shell: true } | ||
@@ -108,7 +171,7 @@ ).returning(new Error('EXPLODED TRYING TO INSTALL')) | ||
await npxImportFailed( | ||
'broken-install', | ||
'broken-install@^2.0.0', | ||
matchesAllLines( | ||
`EXPLODED TRYING TO INSTALL`, | ||
`You should install broken-install locally:`, | ||
`pnpm add -D broken-install@latest` | ||
`pnpm add -D broken-install@^2.0.0` | ||
) | ||
@@ -218,8 +281,5 @@ ) | ||
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) }) | ||
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 }) | ||
@@ -244,3 +304,32 @@ | ||
}) | ||
test(`Should escape versions to be path-safe`, async () => { | ||
const npxDirectoryHash = randomString(12) | ||
const basePath = `/Users/glen/.npm/_npx/${npxDirectoryHash}/node_modules` | ||
expectExecaCommand('npx --version').returning({ stdout: '8.1.2' }) | ||
expectExecaCommand( | ||
`npx -y -p 'pkg-a@>1.0.0' -p 'pkg-b@*' node -e 'console.log(process.env.PATH)'`, | ||
{ | ||
shell: true, | ||
} | ||
).returning({ stdout: getNpxPath(npxDirectoryHash) }) | ||
expectRelativeImport(basePath, 'pkg-a').returning({ name: 'pkg-a', foo: 1 }) | ||
expectRelativeImport(basePath, 'pkg-b').returning({ name: 'pkg-b', bar: 2 }) | ||
const imported = await npxImportSucceeded( | ||
['pkg-a@>1.0.0', 'pkg-b@*'], | ||
matchesAllLines( | ||
'Packages pkg-a, pkg-b not available locally. Attempting to use npx to install temporarily.', | ||
`Installing... (npx -y -p 'pkg-a@>1.0.0' -p 'pkg-b@*')`, | ||
`Installed into ${basePath}.`, | ||
`To skip this step in future, run: pnpm add -D 'pkg-a@>1.0.0' 'pkg-b@*'` | ||
) | ||
) | ||
expect(imported).toStrictEqual([ | ||
{ name: 'pkg-a', foo: 1 }, | ||
{ name: 'pkg-b', bar: 2 }, | ||
]) | ||
}) | ||
}) | ||
}) |
@@ -44,3 +44,9 @@ import { expect, MockedFunction } from 'vitest' | ||
export async function npxImportFailed(pkg: string, errorMatcher: string | RegExp) { | ||
export async function pkgParseFailed(pkg: string | string[], errorMatcher: string | RegExp) { | ||
await expect(async () => { | ||
await npxImport(pkg, NOOP_LOGGER) | ||
}).rejects.toThrowError(errorMatcher) | ||
} | ||
export async function npxImportFailed(pkg: string | string[], errorMatcher: string | RegExp) { | ||
_import.mockRejectedValueOnce('not-found') | ||
@@ -50,3 +56,3 @@ await expect(async () => { | ||
}).rejects.toThrowError(errorMatcher) | ||
expect(_import).toHaveBeenCalledOnce() | ||
expect(_import).toHaveBeenCalled() | ||
} | ||
@@ -53,0 +59,0 @@ |
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
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
50198
751
263
4
6
+ Addedbuiltins@5.1.0(transitive)
+ Addedvalidate-npm-package-name@4.0.0(transitive)